[
  {
    "path": ".claude/commands/release/generate-release-notes.md",
    "content": "---\ndescription: Generate formatted release notes for Yaak releases\nallowed-tools: Bash(git tag:*)\n---\n\nGenerate formatted release notes for Yaak releases by analyzing git history and pull request descriptions.\n\n## What to do\n\n1. Identifies the version tag and previous version\n2. Retrieves all commits between versions\n   - If the version is a beta version, it retrieves commits between the beta version and previous beta version\n   - If the version is a stable version, it retrieves commits between the stable version and the previous stable version\n3. Fetches PR descriptions for linked issues to find:\n   - Feedback URLs (feedback.yaak.app)\n   - Additional context and descriptions\n   - Installation links for plugins\n4. Formats the release notes using the standard Yaak format:\n   - Changelog badge at the top\n   - Bulleted list of changes with PR links\n   - Feedback links where available\n   - Full changelog comparison link at the bottom\n\n## Output Format\n\nThe skill generates markdown-formatted release notes following this structure:\n\n```markdown\n[![Changelog](https://img.shields.io/badge/Changelog-VERSION-blue)](https://yaak.app/changelog/VERSION)\n\n- Feature/fix description in by @username [#123](https://github.com/mountain-loop/yaak/pull/123)\n- [Linked feedback item](https://feedback.yaak.app/p/item) by @username in [#456](https://github.com/mountain-loop/yaak/pull/456)\n- A simple item that doesn't have a feedback or PR link\n\n**Full Changelog**: https://github.com/mountain-loop/yaak/compare/vPREV...vCURRENT\n```\n\n**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last\n**IMPORTANT**: PRs by `@gschier` should not mention the @username\n**IMPORTANT**: These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.\n\n## After Generating Release Notes\n\nAfter outputting the release notes, ask the user if they would like to create a draft GitHub release with these notes. If they confirm, create the release using:\n\n```bash\ngh release create <tag> --draft --prerelease --title \"Release <version>\" --notes '<release notes>'\n```\n\n**IMPORTANT**: The release title format is \"Release XXXX\" where XXXX is the version WITHOUT the `v` prefix. For example, tag `v2026.2.1-beta.1` gets title \"Release 2026.2.1-beta.1\".\n"
  },
  {
    "path": ".claude/rules.md",
    "content": "# Project Rules\n\n## General Development\n\n- **NEVER** commit or push without explicit confirmation\n\n## Build and Lint\n\n- **ALWAYS** run `npm run lint` after modifying TypeScript or JavaScript files\n- Run `npm run bootstrap` after changing plugin runtime or MCP server code\n\n## Plugin System\n\n### Backend Constraints\n\n- Always use `UpdateSource::Plugin` when calling database methods from plugin events\n- Never send timestamps (`createdAt`, `updatedAt`) from TypeScript - Rust backend controls these\n- Backend uses `NaiveDateTime` (no timezone) so avoid sending ISO timestamp strings\n\n### MCP Server\n\n- MCP server has **no active window context** - cannot call `window.workspaceId()`\n- Get workspace ID from `workspaceCtx.yaak.workspace.list()` instead\n\n## Rust Type Generation\n\n- Run `cargo test --package yaak-plugins` (and for other crates) to regenerate TypeScript bindings after modifying Rust event types\n"
  },
  {
    "path": ".claude-context.md",
    "content": "# Claude Context: Detaching Tauri from Yaak\n\n## Goal\n\nMake Yaak runnable as a standalone CLI without Tauri as a dependency. The core Rust crates in `crates/` should be usable independently, while Tauri-specific code lives in `crates-tauri/`.\n\n## Project Structure\n\n```\ncrates/           # Core crates - should NOT depend on Tauri\ncrates-tauri/     # Tauri-specific crates (yaak-app, yaak-tauri-utils, etc.)\ncrates-cli/       # CLI crate (yaak-cli)\n```\n\n## Completed Work\n\n### 1. Folder Restructure\n\n- Moved Tauri-dependent app code to `crates-tauri/yaak-app/`\n- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)\n- Created `crates-cli/yaak-cli/` for the standalone CLI\n\n### 2. Decoupled Crates (no longer depend on Tauri)\n\n- **yaak-models**: Uses `init_standalone()` pattern for CLI database access\n- **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup\n- **yaak-common**: Only contains Tauri-free utilities (serde, platform)\n- **yaak-crypto**: Removed Tauri plugin, EncryptionManager initialized in yaak-app setup, commands moved to yaak-app\n- **yaak-grpc**: Replaced AppHandle with GrpcConfig struct, uses tokio::process::Command instead of Tauri sidecar\n\n### 3. CLI Implementation\n\n- Basic CLI at `crates-cli/yaak-cli/src/main.rs`\n- Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create\n- Uses same database as Tauri app via `yaak_models::init_standalone()`\n\n## Remaining Work\n\n### Crates Still Depending on Tauri (in `crates/`)\n\n1. **yaak-git** (3 files) - Moderate complexity\n2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication\n3. **yaak-sync** (4 files) - Moderate complexity\n4. **yaak-ws** (5 files) - Moderate complexity\n\n### Pattern for Decoupling\n\n1. Remove Tauri plugin `init()` function from the crate\n2. Move commands to `yaak-app/src/commands.rs` or keep inline in `lib.rs`\n3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils\n4. Initialize managers in yaak-app's `.setup()` block\n5. Remove `tauri` from Cargo.toml dependencies\n6. Update `crates-tauri/yaak-app/capabilities/default.json` to remove the plugin permission\n7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`\n\n## Key Files\n\n- `crates-tauri/yaak-app/src/lib.rs` - Main Tauri app, setup block initializes managers\n- `crates-tauri/yaak-app/src/commands.rs` - Migrated Tauri commands\n- `crates-tauri/yaak-app/src/models_ext.rs` - Database plugin and extension traits\n- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state\n- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage\n\n## Git Branch\n\nWorking on `detach-tauri` branch.\n\n## Recent Commits\n\n```\nc40cff40 Remove Tauri dependencies from yaak-crypto and yaak-grpc\ndf495f1d Move Tauri utilities from yaak-common to yaak-tauri-utils\n481e0273 Remove Tauri dependencies from yaak-http and yaak-common\n10568ac3 Add HTTP request sending to yaak-cli\nbcb7d600 Add yaak-cli stub with basic database access\ne718a5f1 Refactor models_ext to use init_standalone from yaak-models\n```\n\n## Testing\n\n- Run `cargo check -p <crate>` to verify a crate builds without Tauri\n- Run `npm run app-dev` to test the Tauri app still works\n- Run `cargo run -p yaak-cli -- --help` to test the CLI\n"
  },
  {
    "path": ".codex/skills/release-generate-release-notes/SKILL.md",
    "content": "---\nname: release-generate-release-notes\ndescription: Generate Yaak release notes from git history and PR metadata, including feedback links and full changelog compare links. Use when asked to run or replace the old Claude generate-release-notes command.\n---\n\n# Generate Release Notes\n\nGenerate formatted markdown release notes for a Yaak tag.\n\n## Workflow\n\n1. Determine target tag.\n2. Determine previous comparable tag:\n   - Beta tag: compare against previous beta (if the root version is the same) or stable tag.\n   - Stable tag: compare against previous stable tag.\n3. Collect commits in range:\n   - `git log --oneline <prev_tag>..<target_tag>`\n4. For linked PRs, fetch metadata:\n   - `gh pr view <PR_NUMBER> --json number,title,body,author,url`\n5. Extract useful details:\n   - Feedback URLs (`feedback.yaak.app`)\n   - Plugin install links or other notable context\n6. Format notes using Yaak style:\n   - Changelog badge at top\n   - Bulleted items with PR links where available\n   - Feedback links where available\n   - Full changelog compare link at bottom\n\n## Formatting Rules\n\n- Wrap final notes in a markdown code fence.\n- Keep a blank line before and after the code fence.\n- Output the markdown code block last.\n- Do not append `by @gschier` for PRs authored by `@gschier`.\n- These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.\n\n## Release Creation Prompt\n\nAfter producing notes, ask whether to create a draft GitHub release.\n\nIf confirmed and release does not yet exist, run:\n\n`gh release create <tag> --draft --prerelease --title \"Release <version_without_v>\" --notes '<release notes>'`\n\nIf a draft release for the tag already exists, update it instead:\n\n`gh release edit <tag> --title \"Release <version_without_v>\" --notes-file <path_to_notes>`\n\nUse title format `Release <version_without_v>`, e.g. `v2026.2.1-beta.1` -> `Release 2026.2.1-beta.1`.\n"
  },
  {
    "path": ".gitattributes",
    "content": "crates-tauri/yaak-app/vendored/**/* linguist-generated=true\ncrates-tauri/yaak-app/gen/schemas/**/* linguist-generated=true\n**/bindings/* linguist-generated=true\ncrates/yaak-templates/pkg/* linguist-generated=true\n\n# Ensure consistent line endings for test files that check exact content\ncrates/yaak-http/tests/test.txt text eol=lf\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: gschier\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: \"\"\nlabels: \"\"\nassignees: \"\"\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n\n- OS: [e.g. iOS]\n- Browser [e.g. chrome, safari]\n- Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n\n- Device: [e.g. iPhone6]\n- OS: [e.g. iOS8.1]\n- Browser [e.g. stock browser, safari]\n- Version [e.g. 22]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: Bugs, Feedback, Feature Requests, and Questions\n    url: https://feedback.yaak.app\n    about: \"Please report to Yaak's public feedback board. Issues will be created and linked here when applicable.\"\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "## Summary\n\n<!-- Describe the bug and the fix in 1-3 sentences. -->\n\n## Submission\n\n- [ ] This PR is a bug fix or small-scope improvement.\n- [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.\n- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).\n- [ ] I tested this change locally.\n- [ ] I added or updated tests when reasonable.\n\nApproved feedback item (required if not a bug fix or small-scope improvement):\n\n<!-- https://yaak.app/feedback/... -->\n\n## Related\n\n<!-- Link related issues, discussions, or feedback items. -->\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "on:\n  pull_request:\n  push:\n    branches:\n      - main\n\nname: Lint and Test\npermissions:\n  contents: read\n\njobs:\n  test:\n    name: Lint/Test\n    runs-on: macos-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: voidzero-dev/setup-vp@v1\n        with:\n          node-version: \"24\"\n          cache: true\n      - uses: dtolnay/rust-toolchain@stable\n      - uses: Swatinem/rust-cache@v2\n        with:\n          shared-key: ci\n          cache-on-failure: true\n\n      - run: vp install\n      - run: npm run bootstrap\n      - run: npm run lint\n      - name: Run JS Tests\n        run: vp test\n      - name: Run Rust Tests\n        run: cargo test --all\n"
  },
  {
    "path": ".github/workflows/claude.yml",
    "content": "name: Claude Code\n\non:\n  issue_comment:\n    types: [created]\n  pull_request_review_comment:\n    types: [created]\n  issues:\n    types: [opened, assigned]\n  pull_request_review:\n    types: [submitted]\n\njobs:\n  claude:\n    if: |\n      (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||\n      (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||\n      (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||\n      (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      pull-requests: read\n      issues: read\n      id-token: write\n      actions: read # Required for Claude to read CI results on PRs\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 1\n\n      - name: Run Claude Code\n        id: claude\n        uses: anthropics/claude-code-action@v1\n        with:\n          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}\n\n          # This is an optional setting that allows Claude to read CI results on PRs\n          additional_permissions: |\n            actions: read\n\n          # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.\n          # prompt: 'Update the pull request description to include a summary of changes.'\n\n          # Optional: Add claude_args to customize behavior and configuration\n          # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md\n          # or https://code.claude.com/docs/en/cli-reference for available options\n          # claude_args: '--allowed-tools Bash(gh pr:*)'\n"
  },
  {
    "path": ".github/workflows/flathub.yml",
    "content": "name: Update Flathub\non:\n  release:\n    types: [published]\n\npermissions:\n  contents: read\n\njobs:\n  update-flathub:\n    name: Update Flathub manifest\n    runs-on: ubuntu-latest\n    # Only run for stable releases (skip betas/pre-releases)\n    if: ${{ !github.event.release.prerelease }}\n    steps:\n      - name: Checkout app repo\n        uses: actions/checkout@v4\n\n      - name: Checkout Flathub repo\n        uses: actions/checkout@v4\n        with:\n          repository: flathub/app.yaak.Yaak\n          token: ${{ secrets.FLATHUB_TOKEN }}\n          path: flathub-repo\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.12\"\n\n      - name: Set up Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: \"22\"\n\n      - name: Install source generators\n        run: |\n          pip install flatpak-node-generator tomlkit aiohttp\n          git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools\n\n      - name: Run update-manifest.sh\n        run: bash flatpak/update-manifest.sh \"${{ github.event.release.tag_name }}\" flathub-repo\n\n      - name: Commit and push to Flathub\n        working-directory: flathub-repo\n        run: |\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"github-actions[bot]@users.noreply.github.com\"\n          git add -A\n          git diff --cached --quiet && echo \"No changes to commit\" && exit 0\n          git commit -m \"Update to ${{ github.event.release.tag_name }}\"\n          git push\n"
  },
  {
    "path": ".github/workflows/release-api-npm.yml",
    "content": "name: Release API to NPM\n\non:\n  push:\n    tags: [yaak-api-*]\n  workflow_dispatch:\n    inputs:\n      version:\n        description: API version to publish (for example 0.9.0 or v0.9.0)\n        required: true\n        type: string\n\npermissions:\n  contents: read\n\njobs:\n  publish-npm:\n    name: Publish @yaakapp/api\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      id-token: write\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: lts/*\n          registry-url: https://registry.npmjs.org\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Set @yaakapp/api version\n        shell: bash\n        env:\n          WORKFLOW_VERSION: ${{ inputs.version }}\n        run: |\n          set -euo pipefail\n          if [ \"${{ github.event_name }}\" = \"workflow_dispatch\" ]; then\n            VERSION=\"$WORKFLOW_VERSION\"\n          else\n            VERSION=\"${GITHUB_REF_NAME#yaak-api-}\"\n          fi\n          VERSION=\"${VERSION#v}\"\n          echo \"Preparing @yaakapp/api version: $VERSION\"\n          cd packages/plugin-runtime-types\n          npm version \"$VERSION\" --no-git-tag-version --allow-same-version\n\n      - name: Build @yaakapp/api\n        working-directory: packages/plugin-runtime-types\n        run: npm run build\n\n      - name: Publish @yaakapp/api\n        working-directory: packages/plugin-runtime-types\n        run: npm publish --provenance --access public\n"
  },
  {
    "path": ".github/workflows/release-app.yml",
    "content": "name: Release App Artifacts\non:\n  push:\n    tags: [v*]\n\njobs:\n  build-artifacts:\n    permissions:\n      contents: write\n\n    name: Build\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - platform: \"macos-latest\" # for Arm-based Macs (M1 and above).\n            args: \"--target aarch64-apple-darwin\"\n            yaak_arch: \"arm64\"\n            os: \"macos\"\n            targets: \"aarch64-apple-darwin\"\n          - platform: \"macos-latest\" # for Intel-based Macs.\n            args: \"--target x86_64-apple-darwin\"\n            yaak_arch: \"x64\"\n            os: \"macos\"\n            targets: \"x86_64-apple-darwin\"\n          - platform: \"ubuntu-22.04\"\n            args: \"\"\n            yaak_arch: \"x64\"\n            os: \"ubuntu\"\n            targets: \"\"\n          - platform: \"ubuntu-22.04-arm\"\n            args: \"\"\n            yaak_arch: \"arm64\"\n            os: \"ubuntu\"\n            targets: \"\"\n          - platform: \"windows-latest\"\n            args: \"\"\n            yaak_arch: \"x64\"\n            os: \"windows\"\n            targets: \"\"\n          # Windows ARM64\n          - platform: \"windows-latest\"\n            args: \"--target aarch64-pc-windows-msvc\"\n            yaak_arch: \"arm64\"\n            os: \"windows\"\n            targets: \"aarch64-pc-windows-msvc\"\n    runs-on: ${{ matrix.platform }}\n    timeout-minutes: 40\n    steps:\n      - name: Checkout yaakapp/app\n        uses: actions/checkout@v4\n\n      - name: Setup Vite+\n        uses: voidzero-dev/setup-vp@v1\n        with:\n          node-version: \"24\"\n          cache: true\n\n      - name: install Rust stable\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          targets: ${{ matrix.targets }}\n\n      - uses: Swatinem/rust-cache@v2\n        with:\n          shared-key: ci\n          cache-on-failure: true\n\n      - name: install dependencies (Linux only)\n        if: matrix.os == 'ubuntu'\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils\n\n      - name: Install Protoc for plugin-runtime\n        uses: arduino/setup-protoc@v3\n        with:\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Install trusted-signing-cli (Windows only)\n        if: matrix.os == 'windows'\n        shell: pwsh\n        run: |\n          $ErrorActionPreference = 'Stop'\n          $dir = \"$env:USERPROFILE\\trusted-signing\"\n          New-Item -ItemType Directory -Force -Path $dir | Out-Null\n          $url = \"https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe\"\n          $exe = Join-Path $dir \"trusted-signing-cli.exe\"\n          Invoke-WebRequest -Uri $url -OutFile $exe\n          echo $dir >> $env:GITHUB_PATH\n          & $exe --version\n\n      - run: vp install\n      - run: npm run bootstrap\n        env:\n          YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}\n      - run: npm run lint\n      - name: Run JS Tests\n        run: vp test\n      - name: Run Rust Tests\n        run: cargo test --all --exclude yaak-cli\n\n      - name: Set version\n        run: npm run replace-version\n        env:\n          YAAK_VERSION: ${{ github.ref_name }}\n\n      - name: Sign vendored binaries (macOS only)\n        if: matrix.os == 'macos'\n        env:\n          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}\n          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}\n          APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}\n          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}\n        run: |\n          # Create keychain\n          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db\n          security create-keychain -p \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH\n          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH\n          security unlock-keychain -p \"$KEYCHAIN_PASSWORD\" $KEYCHAIN_PATH\n\n          # Import certificate\n          echo \"$APPLE_CERTIFICATE\" | base64 --decode > certificate.p12\n          security import certificate.p12 -P \"$APPLE_CERTIFICATE_PASSWORD\" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH\n          security list-keychain -d user -s $KEYCHAIN_PATH\n\n          # Sign vendored binaries with hardened runtime and their specific entitlements\n          codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaakprotoc.plist --sign \"$APPLE_SIGNING_IDENTITY\" crates-tauri/yaak-app/vendored/protoc/yaakprotoc || true\n          codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaaknode.plist --sign \"$APPLE_SIGNING_IDENTITY\" crates-tauri/yaak-app/vendored/node/yaaknode || true\n\n      - uses: tauri-apps/tauri-action@v0\n        env:\n          YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}\n\n          ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}\n          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}\n\n          # Apple signing stuff\n          APPLE_CERTIFICATE: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE }}\n          APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }}\n          APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }}\n          APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }}\n          APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }}\n          APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }}\n\n          # Windows signing stuff\n          AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }}\n          AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}\n          AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}\n        with:\n          tagName: \"v__VERSION__\"\n          releaseName: \"Release __VERSION__\"\n          releaseBody: \"[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)\"\n          releaseDraft: true\n          prerelease: true\n          args: \"${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json\"\n\n      # Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)\n      - name: Build and upload machine-wide installer (Windows only)\n        if: matrix.os == 'windows'\n        shell: pwsh\n        env:\n          YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}\n          AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}\n          AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}\n          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}\n          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}\n        run: |\n          Get-ChildItem -Recurse -Path target -File -Filter \"*.exe.sig\" | Remove-Item -Force\n          npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./crates-tauri/yaak-app/tauri.release.conf.json --config '{\"bundle\":{\"createUpdaterArtifacts\":true,\"windows\":{\"nsis\":{\"installMode\":\"perMachine\"}}}}'\n          $setup = Get-ChildItem -Recurse -Path target -Filter \"*setup*.exe\" | Select-Object -First 1\n          $setupSig = \"$($setup.FullName).sig\"\n          $dest = $setup.FullName -replace '-setup\\.exe$', '-setup-machine.exe'\n          $destSig = \"$dest.sig\"\n          Copy-Item $setup.FullName $dest\n          Copy-Item $setupSig $destSig\n          gh release upload \"${{ github.ref_name }}\" \"$dest\" --clobber\n          gh release upload \"${{ github.ref_name }}\" \"$destSig\" --clobber\n"
  },
  {
    "path": ".github/workflows/release-cli-npm.yml",
    "content": "name: Release CLI to NPM\n\non:\n  push:\n    tags: [yaak-cli-*]\n  workflow_dispatch:\n    inputs:\n      version:\n        description: CLI version to publish (for example 0.4.0 or v0.4.0)\n        required: true\n        type: string\n\npermissions:\n  contents: read\n\njobs:\n  prepare-vendored-assets:\n    name: Prepare vendored plugin assets\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: lts/*\n\n      - name: Install Rust stable\n        uses: dtolnay/rust-toolchain@stable\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Build plugin assets\n        env:\n          SKIP_WASM_BUILD: \"1\"\n        run: |\n          npm run build\n          npm run vendor:vendor-plugins\n\n      - name: Upload vendored assets\n        uses: actions/upload-artifact@v4\n        with:\n          name: vendored-assets\n          path: |\n            crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs\n            crates-tauri/yaak-app/vendored/plugins\n          if-no-files-found: error\n\n  build-binaries:\n    name: Build ${{ matrix.pkg }}\n    needs: prepare-vendored-assets\n    runs-on: ${{ matrix.runner }}\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - pkg: cli-darwin-arm64\n            runner: macos-latest\n            target: aarch64-apple-darwin\n            binary: yaak\n          - pkg: cli-darwin-x64\n            runner: macos-latest\n            target: x86_64-apple-darwin\n            binary: yaak\n          - pkg: cli-linux-arm64\n            runner: ubuntu-22.04-arm\n            target: aarch64-unknown-linux-gnu\n            binary: yaak\n          - pkg: cli-linux-x64\n            runner: ubuntu-22.04\n            target: x86_64-unknown-linux-gnu\n            binary: yaak\n          - pkg: cli-win32-arm64\n            runner: windows-latest\n            target: aarch64-pc-windows-msvc\n            binary: yaak.exe\n          - pkg: cli-win32-x64\n            runner: windows-latest\n            target: x86_64-pc-windows-msvc\n            binary: yaak.exe\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Install Rust stable\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          targets: ${{ matrix.target }}\n\n      - name: Restore Rust cache\n        uses: Swatinem/rust-cache@v2\n        with:\n          shared-key: release-cli-npm\n          cache-on-failure: true\n\n      - name: Install Linux build dependencies\n        if: startsWith(matrix.runner, 'ubuntu')\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y pkg-config libdbus-1-dev\n\n      - name: Download vendored assets\n        uses: actions/download-artifact@v4\n        with:\n          name: vendored-assets\n          path: crates-tauri/yaak-app/vendored\n\n      - name: Set CLI build version\n        shell: bash\n        env:\n          WORKFLOW_VERSION: ${{ inputs.version }}\n        run: |\n          set -euo pipefail\n          if [ \"${{ github.event_name }}\" = \"workflow_dispatch\" ]; then\n            VERSION=\"$WORKFLOW_VERSION\"\n          else\n            VERSION=\"${GITHUB_REF_NAME#yaak-cli-}\"\n          fi\n          VERSION=\"${VERSION#v}\"\n          echo \"Building yaak version: $VERSION\"\n          echo \"YAAK_CLI_VERSION=$VERSION\" >> \"$GITHUB_ENV\"\n\n      - name: Build yaak\n        run: cargo build --locked --release -p yaak-cli --bin yaak --target ${{ matrix.target }}\n\n      - name: Stage binary artifact\n        shell: bash\n        run: |\n          set -euo pipefail\n          mkdir -p \"npm/dist/${{ matrix.pkg }}\"\n          cp \"target/${{ matrix.target }}/release/${{ matrix.binary }}\" \"npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}\"\n\n      - name: Upload binary artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: ${{ matrix.pkg }}\n          path: npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}\n          if-no-files-found: error\n\n  publish-npm:\n    name: Publish @yaakapp/cli packages\n    needs: build-binaries\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      id-token: write\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: lts/*\n          registry-url: https://registry.npmjs.org\n\n      - name: Download binary artifacts\n        uses: actions/download-artifact@v4\n        with:\n          pattern: cli-*\n          path: npm/dist\n          merge-multiple: false\n\n      - name: Prepare npm packages\n        shell: bash\n        env:\n          WORKFLOW_VERSION: ${{ inputs.version }}\n        run: |\n          set -euo pipefail\n          if [ \"${{ github.event_name }}\" = \"workflow_dispatch\" ]; then\n            VERSION=\"$WORKFLOW_VERSION\"\n          else\n            VERSION=\"${GITHUB_REF_NAME#yaak-cli-}\"\n          fi\n          VERSION=\"${VERSION#v}\"\n          if [[ \"$VERSION\" == *-* ]]; then\n            PRERELEASE=\"${VERSION#*-}\"\n            NPM_TAG=\"${PRERELEASE%%.*}\"\n          else\n            NPM_TAG=\"latest\"\n          fi\n          echo \"Preparing CLI npm packages for version: $VERSION\"\n          echo \"Publishing with npm dist-tag: $NPM_TAG\"\n          echo \"NPM_TAG=$NPM_TAG\" >> \"$GITHUB_ENV\"\n          YAAK_CLI_VERSION=\"$VERSION\" node npm/prepare-publish.js\n\n      - name: Publish @yaakapp/cli-darwin-arm64\n        run: npm publish --provenance --access public --tag \"$NPM_TAG\"\n        working-directory: npm/cli-darwin-arm64\n\n      - name: Publish @yaakapp/cli-darwin-x64\n        run: npm publish --provenance --access public --tag \"$NPM_TAG\"\n        working-directory: npm/cli-darwin-x64\n\n      - name: Publish @yaakapp/cli-linux-arm64\n        run: npm publish --provenance --access public --tag \"$NPM_TAG\"\n        working-directory: npm/cli-linux-arm64\n\n      - name: Publish @yaakapp/cli-linux-x64\n        run: npm publish --provenance --access public --tag \"$NPM_TAG\"\n        working-directory: npm/cli-linux-x64\n\n      - name: Publish @yaakapp/cli-win32-arm64\n        run: npm publish --provenance --access public --tag \"$NPM_TAG\"\n        working-directory: npm/cli-win32-arm64\n\n      - name: Publish @yaakapp/cli-win32-x64\n        run: npm publish --provenance --access public --tag \"$NPM_TAG\"\n        working-directory: npm/cli-win32-x64\n\n      - name: Publish @yaakapp/cli\n        run: npm publish --provenance --access public --tag \"$NPM_TAG\"\n        working-directory: npm/cli\n"
  },
  {
    "path": ".github/workflows/sponsors.yml",
    "content": "name: Generate Sponsors README\non:\n  workflow_dispatch:\n  schedule:\n    - cron: 30 15 * * 0-6\npermissions:\n  contents: write\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout 🛎️\n        uses: actions/checkout@v2\n\n      - name: Generate Sponsors\n        uses: JamesIves/github-sponsors-readme-action@v1\n        with:\n          token: ${{ secrets.SPONSORS_PAT }}\n          file: \"README.md\"\n          maximum: 1999\n          template: '<a href=\"https://github.com/{{{ login }}}\"><img src=\"{{{ avatarUrl }}}\" width=\"50px\" alt=\"User avatar: {{{ login }}}\" /></a>&nbsp;&nbsp;'\n          active-only: false\n          include-private: true\n          marker: \"sponsors-base\"\n\n      - name: Generate Sponsors\n        uses: JamesIves/github-sponsors-readme-action@v1\n        with:\n          token: ${{ secrets.SPONSORS_PAT }}\n          file: \"README.md\"\n          minimum: 2000\n          template: '<a href=\"https://github.com/{{{ login }}}\"><img src=\"{{{ avatarUrl }}}\" width=\"80px\" alt=\"User avatar: {{{ login }}}\" /></a>&nbsp;&nbsp;'\n          active-only: false\n          include-private: true\n          marker: \"sponsors-premium\"\n\n      # ⚠️ Note: You can use any deployment step here to automatically push the README\n      # changes back to your branch.\n      - name: Commit Changes\n        uses: JamesIves/github-pages-deploy-action@v4\n        with:\n          branch: main\n          force: false\n          folder: \".\"\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n!.vscode/settings.json\n!.vscode/launch.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n.eslintcache\nout\n\n*.sqlite\n*.sqlite-*\n\n.cargo\n\n.tmp\ntmp\n.zed\ncodebook.toml\ntarget\n\n# Per-worktree Tauri config (generated by post-checkout hook)\ncrates-tauri/yaak-app/tauri.worktree.conf.json\n\n# Tauri auto-generated permission files\n**/permissions/autogenerated\n**/permissions/schemas\n\n# Flatpak build artifacts\nflatpak-repo/\n.flatpak-builder/\nflatpak/flatpak-builder-tools/\nflatpak/cargo-sources.json\nflatpak/node-sources.json\n\n# Local Codex desktop env state\n.codex/environments/environment.toml\n\n# Claude Code local settings\n.claude/settings.local.json\n"
  },
  {
    "path": ".node-version",
    "content": "24.14.0\n"
  },
  {
    "path": ".npmrc",
    "content": "# vite-plugin-wasm has not yet declared Vite 8 in its peerDependencies\nlegacy-peer-deps=true\n"
  },
  {
    "path": ".nvmrc",
    "content": "20\n"
  },
  {
    "path": ".oxfmtignore",
    "content": "**/bindings/**\ncrates/yaak-templates/pkg/**\n"
  },
  {
    "path": ".vite-hooks/post-checkout",
    "content": "node scripts/git-hooks/post-checkout.mjs \"$@\"\n"
  },
  {
    "path": ".vite-hooks/pre-commit",
    "content": "vp lint\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\n    \"rust-lang.rust-analyzer\",\n    \"bradlc.vscode-tailwindcss\",\n    \"VoidZero.vite-plus-extension-pack\"\n  ]\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"name\": \"Dev App\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"start\"]\n    },\n    {\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"name\": \"Build App\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"start\"]\n    },\n    {\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"name\": \"Bootstrap\",\n      \"runtimeExecutable\": \"npm\",\n      \"runtimeArgs\": [\"run\", \"bootstrap\"]\n    }\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"editor.defaultFormatter\": \"oxc.oxc-vscode\",\n  \"editor.formatOnSave\": true,\n  \"editor.formatOnSaveMode\": \"file\",\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.oxc\": \"explicit\"\n  }\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging.\n- Do not commit, push, or tag without explicit approval\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Yaak\n\nYaak accepts community pull requests for:\n\n- Bug fixes\n- Small-scope improvements directly tied to existing behavior\n\nPull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first.\n\n## Approval for Non-Bugfix Changes\n\nIf your PR is not a bug fix or small-scope improvement, include a link to the approved [feedback item](https://yaak.app/feedback) where contribution approval was explicitly stated.\n\n## Development Setup\n\nFor local setup and development workflows, see [`DEVELOPMENT.md`](DEVELOPMENT.md).\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[workspace]\nresolver = \"2\"\nmembers = [\n  \"crates/yaak\",\n  # Shared crates (no Tauri dependency)\n  \"crates/yaak-core\",\n  \"crates/yaak-common\",\n  \"crates/yaak-crypto\",\n  \"crates/yaak-git\",\n  \"crates/yaak-grpc\",\n  \"crates/yaak-http\",\n  \"crates/yaak-models\",\n  \"crates/yaak-plugins\",\n  \"crates/yaak-sse\",\n  \"crates/yaak-sync\",\n  \"crates/yaak-templates\",\n  \"crates/yaak-tls\",\n  \"crates/yaak-ws\",\n  \"crates/yaak-api\",\n  # CLI crates\n  \"crates-cli/yaak-cli\",\n  # Tauri-specific crates\n  \"crates-tauri/yaak-app\",\n  \"crates-tauri/yaak-fonts\",\n  \"crates-tauri/yaak-license\",\n  \"crates-tauri/yaak-mac-window\",\n  \"crates-tauri/yaak-tauri-utils\",\n]\n\n[workspace.dependencies]\nchrono = \"0.4.42\"\nhex = \"0.4.3\"\nkeyring = \"3.6.3\"\nlog = \"0.4.29\"\nreqwest = \"0.12.20\"\nrustls = { version = \"0.23.34\", default-features = false }\nrustls-platform-verifier = \"0.6.2\"\nschemars = { version = \"0.8.22\", features = [\"chrono\"] }\nserde = \"1.0.228\"\nserde_json = \"1.0.145\"\nsha2 = \"0.10.9\"\ntauri = \"2.9.5\"\ntauri-plugin = \"2.5.2\"\ntauri-plugin-dialog = \"2.4.2\"\ntauri-plugin-shell = \"2.3.3\"\nthiserror = \"2.0.17\"\ntokio = \"1.48.0\"\nts-rs = \"11.1.0\"\n\n# Internal crates - shared\nyaak-core = { path = \"crates/yaak-core\" }\nyaak = { path = \"crates/yaak\" }\nyaak-common = { path = \"crates/yaak-common\" }\nyaak-crypto = { path = \"crates/yaak-crypto\" }\nyaak-git = { path = \"crates/yaak-git\" }\nyaak-grpc = { path = \"crates/yaak-grpc\" }\nyaak-http = { path = \"crates/yaak-http\" }\nyaak-models = { path = \"crates/yaak-models\" }\nyaak-plugins = { path = \"crates/yaak-plugins\" }\nyaak-sse = { path = \"crates/yaak-sse\" }\nyaak-sync = { path = \"crates/yaak-sync\" }\nyaak-templates = { path = \"crates/yaak-templates\" }\nyaak-tls = { path = \"crates/yaak-tls\" }\nyaak-ws = { path = \"crates/yaak-ws\" }\nyaak-api = { path = \"crates/yaak-api\" }\n\n# Internal crates - Tauri-specific\nyaak-fonts = { path = \"crates-tauri/yaak-fonts\" }\nyaak-license = { path = \"crates-tauri/yaak-license\" }\nyaak-mac-window = { path = \"crates-tauri/yaak-mac-window\" }\nyaak-tauri-utils = { path = \"crates-tauri/yaak-tauri-utils\" }\n\n[profile.release]\nstrip = false\n"
  },
  {
    "path": "DEVELOPMENT.md",
    "content": "# Developer Setup\n\nYaak is a combined Node.js and Rust monorepo. It is a [Tauri](https://tauri.app) project, so\nuses Rust and HTML/CSS/JS for the main application but there is also a plugin system powered\nby a Node.js sidecar that communicates to the app over gRPC.\n\nBecause of the moving parts, there are a few setup steps required before development can\nbegin.\n\n## Prerequisites\n\nMake sure you have the following tools installed:\n\n- [Node.js](https://nodejs.org/en/download/package-manager) (v24+)\n- [Rust](https://www.rust-lang.org/tools/install)\n- [Vite+](https://vite.dev/guide/vite-plus) (`vp` CLI)\n\nCheck the installations with the following commands:\n\n```shell\nnode -v\nnpm -v\nvp --version\nrustc --version\n```\n\nInstall the NPM dependencies:\n\n```shell\nnpm install\n```\n\nRun the `bootstrap` command to do some initial setup:\n\n```shell\nnpm run bootstrap\n```\n\n## Run the App\n\nAfter bootstrapping, start the app in development mode:\n\n```shell\nnpm start\n```\n\n## SQLite Migrations\n\nNew migrations can be created from the `src-tauri/` directory:\n\n```shell\nnpm run migration\n```\n\nRerun the app to apply the migrations.\n\n_Note: For safety, development builds use a separate database location from production builds._\n\n## Lezer Grammar Generation\n\n```sh\n# Example\nlezer-generator components/core/Editor/<LANG>/<LANG>.grammar > components/core/Editor/<LANG>/<LANG>.ts\n```\n\n## Linting and Formatting\n\nThis repo uses [Vite+](https://vite.dev/guide/vite-plus) for linting (oxlint) and formatting (oxfmt).\n\n- Lint the entire repo:\n\n```sh\nnpm run lint\n```\n\n- Format code:\n\n```sh\nnpm run format\n```\n\nNotes:\n\n- A pre-commit hook runs `vp lint` automatically on commit.\n- Some workspace packages also run `tsc --noEmit` for type-checking.\n- VS Code users should install the recommended extensions for format-on-save support.\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 Yaak\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <a href=\"https://github.com/JamesIves/github-sponsors-readme-action\">\n    <img width=\"200px\" src=\"https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app/icons/icon.png\">\n  </a>\n</p>\n\n<h1 align=\"center\">\n  💫 Yaak ➟ Desktop API Client 💫\n</h1>\n\n<p align=\"center\">\n    A fast, privacy-first API client for REST, GraphQL, SSE, WebSocket, and gRPC – built with Tauri, Rust, and React.\n</p>\n<p align=\"center\">\n Development is funded by community-purchased <a href=\"https://yaak.app/pricing\">licenses</a>. You can also <a href=\"https://github.com/sponsors/gschier\">become a sponsor</a> to have your logo appear below. 💖\n</p>\n<br>\n\n<p align=\"center\">\n  <!-- sponsors-premium --><a href=\"https://github.com/MVST-Solutions\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png\" width=\"80px\" alt=\"User avatar: MVST-Solutions\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/dharsanb\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png\" width=\"80px\" alt=\"User avatar: dharsanb\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/railwayapp\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png\" width=\"80px\" alt=\"User avatar: railwayapp\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/caseyamcl\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png\" width=\"80px\" alt=\"User avatar: caseyamcl\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/bytebase\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;bytebase.png\" width=\"80px\" alt=\"User avatar: bytebase\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/\"><img src=\"https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png\" width=\"80px\" alt=\"User avatar: \" /></a>&nbsp;&nbsp;<!-- sponsors-premium -->\n</p>\n<p align=\"center\">\n  <!-- sponsors-base --><a href=\"https://github.com/seanwash\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;seanwash.png\" width=\"50px\" alt=\"User avatar: seanwash\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/jerath\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;jerath.png\" width=\"50px\" alt=\"User avatar: jerath\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/itsa-sh\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png\" width=\"50px\" alt=\"User avatar: itsa-sh\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/dmmulroy\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png\" width=\"50px\" alt=\"User avatar: dmmulroy\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/timcole\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;timcole.png\" width=\"50px\" alt=\"User avatar: timcole\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/VLZH\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;VLZH.png\" width=\"50px\" alt=\"User avatar: VLZH\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/terasaka2k\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png\" width=\"50px\" alt=\"User avatar: terasaka2k\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/andriyor\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;andriyor.png\" width=\"50px\" alt=\"User avatar: andriyor\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/majudhu\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;majudhu.png\" width=\"50px\" alt=\"User avatar: majudhu\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/axelrindle\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png\" width=\"50px\" alt=\"User avatar: axelrindle\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/jirizverina\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png\" width=\"50px\" alt=\"User avatar: jirizverina\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/chip-well\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;chip-well.png\" width=\"50px\" alt=\"User avatar: chip-well\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/GRAYAH\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png\" width=\"50px\" alt=\"User avatar: GRAYAH\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/flashblaze\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png\" width=\"50px\" alt=\"User avatar: flashblaze\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/Frostist\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;Frostist.png\" width=\"50px\" alt=\"User avatar: Frostist\" /></a>&nbsp;&nbsp;<!-- sponsors-base -->\n</p>\n\n![Yaak API Client](https://yaak.app/static/screenshot.png)\n\n## Features\n\nYaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.\nBuilt with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight, and private. No telemetry, no VC funding, and no cloud lock-in.\n\n### 🌐 Work with any API\n\n- Import collections from Postman, Insomnia, OpenAPI, Swagger, or Curl.\n- Send requests via REST, GraphQL, gRPC, WebSocket, or Server-Sent Events.\n- Filter and inspect responses with JSONPath or XPath.\n\n### 🔐 Stay secure\n\n- Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication.\n- Secure sensitive values with encrypted secrets.\n- Store secrets in your OS keychain.\n\n### ☁️ Organize & collaborate\n\n- Group requests into workspaces and nested folders.\n- Use environment variables to switch between dev, staging, and prod.\n- Mirror workspaces to your filesystem for versioning in Git or syncing with Dropbox.\n\n### 🧩 Extend & customize\n\n- Insert dynamic values like UUIDs or timestamps with template tags.\n- Pick from built-in themes or build your own.\n- Create plugins to extend authentication, template tags, or the UI.\n\n## Contribution Policy\n\n> [!IMPORTANT]\n> Community PRs are currently limited to bug fixes and small-scope improvements.\n> If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback).\n> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.\n\n## Useful Resources\n\n- [Feedback and Bug Reports](https://feedback.yaak.app)\n- [Documentation](https://yaak.app/docs)\n- [Yaak vs Postman](https://yaak.app/alternatives/postman)\n- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)\n- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)\n"
  },
  {
    "path": "crates/yaak/Cargo.toml",
    "content": "[package]\nname = \"yaak\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nasync-trait = \"0.1\"\nlog = { workspace = true }\nmd5 = \"0.8.0\"\nserde_json = { workspace = true }\nthiserror = { workspace = true }\ntokio = { workspace = true, features = [\"sync\", \"rt\"] }\nyaak-http = { workspace = true }\nyaak-crypto = { workspace = true }\nyaak-models = { workspace = true }\nyaak-plugins = { workspace = true }\nyaak-templates = { workspace = true }\nyaak-tls = { workspace = true }\n\n[dev-dependencies]\ntempfile = \"3\"\n"
  },
  {
    "path": "crates/yaak/src/error.rs",
    "content": "use thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum Error {\n    #[error(transparent)]\n    Send(#[from] crate::send::SendHttpRequestError),\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates/yaak/src/lib.rs",
    "content": "pub mod error;\npub mod plugin_events;\npub mod render;\npub mod send;\n\npub use error::Error;\npub type Result<T> = error::Result<T>;\n"
  },
  {
    "path": "crates/yaak/src/plugin_events.rs",
    "content": "use yaak_models::models::AnyModel;\nuse yaak_models::query_manager::QueryManager;\nuse yaak_models::util::UpdateSource;\nuse yaak_plugins::events::{\n    CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,\n    DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest,\n    FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest,\n    GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload,\n    ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest,\n    ListHttpRequestsResponse, ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest,\n    PromptFormRequest, PromptTextRequest, ReloadResponse, RenderGrpcRequestRequest,\n    RenderHttpRequestRequest, SendHttpRequestRequest, SetKeyValueRequest, ShowToastRequest,\n    TemplateRenderRequest, UpsertModelRequest, UpsertModelResponse, WindowInfoRequest,\n};\n\npub struct SharedPluginEventContext<'a> {\n    pub plugin_name: &'a str,\n    pub workspace_id: Option<&'a str>,\n}\n\n#[derive(Debug)]\npub enum GroupedPluginEvent<'a> {\n    Handled(Option<InternalEventPayload>),\n    ToHandle(HostRequest<'a>),\n}\n\n#[derive(Debug)]\npub enum GroupedPluginRequest<'a> {\n    Shared(SharedRequest<'a>),\n    Host(HostRequest<'a>),\n    Ignore,\n}\n\n#[derive(Debug)]\npub enum SharedRequest<'a> {\n    GetKeyValue(&'a GetKeyValueRequest),\n    SetKeyValue(&'a SetKeyValueRequest),\n    DeleteKeyValue(&'a DeleteKeyValueRequest),\n    GetHttpRequestById(&'a GetHttpRequestByIdRequest),\n    ListFolders(&'a ListFoldersRequest),\n    ListHttpRequests(&'a ListHttpRequestsRequest),\n    FindHttpResponses(&'a FindHttpResponsesRequest),\n    UpsertModel(&'a UpsertModelRequest),\n    DeleteModel(&'a DeleteModelRequest),\n}\n\n#[derive(Debug)]\npub enum HostRequest<'a> {\n    ShowToast(&'a ShowToastRequest),\n    CopyText(&'a CopyTextRequest),\n    PromptText(&'a PromptTextRequest),\n    PromptForm(&'a PromptFormRequest),\n    RenderGrpcRequest(&'a RenderGrpcRequestRequest),\n    RenderHttpRequest(&'a RenderHttpRequestRequest),\n    TemplateRender(&'a TemplateRenderRequest),\n    SendHttpRequest(&'a SendHttpRequestRequest),\n    OpenWindow(&'a OpenWindowRequest),\n    CloseWindow(&'a CloseWindowRequest),\n    OpenExternalUrl(&'a OpenExternalUrlRequest),\n    ListOpenWorkspaces(&'a ListOpenWorkspacesRequest),\n    ListCookieNames(&'a ListCookieNamesRequest),\n    GetCookieValue(&'a GetCookieValueRequest),\n    WindowInfo(&'a WindowInfoRequest),\n    ErrorResponse(&'a ErrorResponse),\n    ReloadResponse(&'a ReloadResponse),\n    OtherRequest(&'a InternalEventPayload),\n}\n\nimpl HostRequest<'_> {\n    pub fn type_name(&self) -> String {\n        match self {\n            HostRequest::ShowToast(_) => \"show_toast_request\".to_string(),\n            HostRequest::CopyText(_) => \"copy_text_request\".to_string(),\n            HostRequest::PromptText(_) => \"prompt_text_request\".to_string(),\n            HostRequest::PromptForm(_) => \"prompt_form_request\".to_string(),\n            HostRequest::RenderGrpcRequest(_) => \"render_grpc_request_request\".to_string(),\n            HostRequest::RenderHttpRequest(_) => \"render_http_request_request\".to_string(),\n            HostRequest::TemplateRender(_) => \"template_render_request\".to_string(),\n            HostRequest::SendHttpRequest(_) => \"send_http_request_request\".to_string(),\n            HostRequest::OpenWindow(_) => \"open_window_request\".to_string(),\n            HostRequest::CloseWindow(_) => \"close_window_request\".to_string(),\n            HostRequest::OpenExternalUrl(_) => \"open_external_url_request\".to_string(),\n            HostRequest::ListOpenWorkspaces(_) => \"list_open_workspaces_request\".to_string(),\n            HostRequest::ListCookieNames(_) => \"list_cookie_names_request\".to_string(),\n            HostRequest::GetCookieValue(_) => \"get_cookie_value_request\".to_string(),\n            HostRequest::WindowInfo(_) => \"window_info_request\".to_string(),\n            HostRequest::ErrorResponse(_) => \"error_response\".to_string(),\n            HostRequest::ReloadResponse(_) => \"reload_response\".to_string(),\n            HostRequest::OtherRequest(payload) => payload.type_name(),\n        }\n    }\n}\n\nimpl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {\n    fn from(payload: &'a InternalEventPayload) -> Self {\n        match payload {\n            InternalEventPayload::GetKeyValueRequest(req) => {\n                GroupedPluginRequest::Shared(SharedRequest::GetKeyValue(req))\n            }\n            InternalEventPayload::SetKeyValueRequest(req) => {\n                GroupedPluginRequest::Shared(SharedRequest::SetKeyValue(req))\n            }\n            InternalEventPayload::DeleteKeyValueRequest(req) => {\n                GroupedPluginRequest::Shared(SharedRequest::DeleteKeyValue(req))\n            }\n            InternalEventPayload::GetHttpRequestByIdRequest(req) => {\n                GroupedPluginRequest::Shared(SharedRequest::GetHttpRequestById(req))\n            }\n            InternalEventPayload::ErrorResponse(resp) => {\n                GroupedPluginRequest::Host(HostRequest::ErrorResponse(resp))\n            }\n            InternalEventPayload::ReloadResponse(req) => {\n                GroupedPluginRequest::Host(HostRequest::ReloadResponse(req))\n            }\n            InternalEventPayload::ListOpenWorkspacesRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::ListOpenWorkspaces(req))\n            }\n            InternalEventPayload::ListFoldersRequest(req) => {\n                GroupedPluginRequest::Shared(SharedRequest::ListFolders(req))\n            }\n            InternalEventPayload::ListHttpRequestsRequest(req) => {\n                GroupedPluginRequest::Shared(SharedRequest::ListHttpRequests(req))\n            }\n            InternalEventPayload::ShowToastRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::ShowToast(req))\n            }\n            InternalEventPayload::CopyTextRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::CopyText(req))\n            }\n            InternalEventPayload::PromptTextRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::PromptText(req))\n            }\n            InternalEventPayload::PromptFormRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::PromptForm(req))\n            }\n            InternalEventPayload::FindHttpResponsesRequest(req) => {\n                GroupedPluginRequest::Shared(SharedRequest::FindHttpResponses(req))\n            }\n            InternalEventPayload::UpsertModelRequest(req) => {\n                GroupedPluginRequest::Shared(SharedRequest::UpsertModel(req))\n            }\n            InternalEventPayload::DeleteModelRequest(req) => {\n                GroupedPluginRequest::Shared(SharedRequest::DeleteModel(req))\n            }\n            InternalEventPayload::RenderGrpcRequestRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::RenderGrpcRequest(req))\n            }\n            InternalEventPayload::RenderHttpRequestRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::RenderHttpRequest(req))\n            }\n            InternalEventPayload::TemplateRenderRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::TemplateRender(req))\n            }\n            InternalEventPayload::SendHttpRequestRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::SendHttpRequest(req))\n            }\n            InternalEventPayload::OpenWindowRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::OpenWindow(req))\n            }\n            InternalEventPayload::CloseWindowRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::CloseWindow(req))\n            }\n            InternalEventPayload::OpenExternalUrlRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::OpenExternalUrl(req))\n            }\n            InternalEventPayload::ListCookieNamesRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::ListCookieNames(req))\n            }\n            InternalEventPayload::GetCookieValueRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::GetCookieValue(req))\n            }\n            InternalEventPayload::WindowInfoRequest(req) => {\n                GroupedPluginRequest::Host(HostRequest::WindowInfo(req))\n            }\n            payload if payload.type_name().ends_with(\"_request\") => {\n                GroupedPluginRequest::Host(HostRequest::OtherRequest(payload))\n            }\n            _ => GroupedPluginRequest::Ignore,\n        }\n    }\n}\n\npub fn handle_shared_plugin_event<'a>(\n    query_manager: &QueryManager,\n    payload: &'a InternalEventPayload,\n    context: SharedPluginEventContext<'_>,\n) -> GroupedPluginEvent<'a> {\n    match GroupedPluginRequest::from(payload) {\n        GroupedPluginRequest::Shared(req) => {\n            GroupedPluginEvent::Handled(Some(build_shared_reply(query_manager, req, context)))\n        }\n        GroupedPluginRequest::Host(req) => GroupedPluginEvent::ToHandle(req),\n        GroupedPluginRequest::Ignore => GroupedPluginEvent::Handled(None),\n    }\n}\n\nfn build_shared_reply(\n    query_manager: &QueryManager,\n    request: SharedRequest<'_>,\n    context: SharedPluginEventContext<'_>,\n) -> InternalEventPayload {\n    match request {\n        SharedRequest::GetKeyValue(req) => {\n            let value = query_manager\n                .connect()\n                .get_plugin_key_value(context.plugin_name, &req.key)\n                .map(|v| v.value);\n            InternalEventPayload::GetKeyValueResponse(GetKeyValueResponse { value })\n        }\n        SharedRequest::SetKeyValue(req) => {\n            query_manager.connect().set_plugin_key_value(context.plugin_name, &req.key, &req.value);\n            InternalEventPayload::SetKeyValueResponse(yaak_plugins::events::SetKeyValueResponse {})\n        }\n        SharedRequest::DeleteKeyValue(req) => {\n            match query_manager.connect().delete_plugin_key_value(context.plugin_name, &req.key) {\n                Ok(deleted) => {\n                    InternalEventPayload::DeleteKeyValueResponse(DeleteKeyValueResponse { deleted })\n                }\n                Err(err) => InternalEventPayload::ErrorResponse(ErrorResponse {\n                    error: format!(\"Failed to delete plugin key '{}' : {err}\", req.key),\n                }),\n            }\n        }\n        SharedRequest::GetHttpRequestById(req) => {\n            let http_request = query_manager.connect().get_http_request(&req.id).ok();\n            InternalEventPayload::GetHttpRequestByIdResponse(GetHttpRequestByIdResponse {\n                http_request,\n            })\n        }\n        SharedRequest::ListFolders(_) => {\n            let Some(workspace_id) = context.workspace_id else {\n                return InternalEventPayload::ErrorResponse(ErrorResponse {\n                    error: \"workspace_id is required for list_folders_request\".to_string(),\n                });\n            };\n            let folders = match query_manager.connect().list_folders(workspace_id) {\n                Ok(folders) => folders,\n                Err(err) => {\n                    return InternalEventPayload::ErrorResponse(ErrorResponse {\n                        error: format!(\"Failed to list folders: {err}\"),\n                    });\n                }\n            };\n            InternalEventPayload::ListFoldersResponse(ListFoldersResponse { folders })\n        }\n        SharedRequest::ListHttpRequests(req) => {\n            let http_requests = if let Some(folder_id) = req.folder_id.as_deref() {\n                match query_manager.connect().list_http_requests_for_folder_recursive(folder_id) {\n                    Ok(http_requests) => http_requests,\n                    Err(err) => {\n                        return InternalEventPayload::ErrorResponse(ErrorResponse {\n                            error: format!(\"Failed to list HTTP requests for folder: {err}\"),\n                        });\n                    }\n                }\n            } else {\n                let Some(workspace_id) = context.workspace_id else {\n                    return InternalEventPayload::ErrorResponse(ErrorResponse {\n                        error:\n                            \"workspace_id is required for list_http_requests_request without folder_id\"\n                                .to_string(),\n                    });\n                };\n                match query_manager.connect().list_http_requests(workspace_id) {\n                    Ok(http_requests) => http_requests,\n                    Err(err) => {\n                        return InternalEventPayload::ErrorResponse(ErrorResponse {\n                            error: format!(\"Failed to list HTTP requests: {err}\"),\n                        });\n                    }\n                }\n            };\n            InternalEventPayload::ListHttpRequestsResponse(ListHttpRequestsResponse {\n                http_requests,\n            })\n        }\n        SharedRequest::FindHttpResponses(req) => {\n            let http_responses = query_manager\n                .connect()\n                .list_http_responses_for_request(&req.request_id, req.limit.map(|l| l as u64))\n                .unwrap_or_default();\n            InternalEventPayload::FindHttpResponsesResponse(FindHttpResponsesResponse {\n                http_responses,\n            })\n        }\n        SharedRequest::UpsertModel(req) => {\n            use AnyModel::*;\n\n            let model = match &req.model {\n                HttpRequest(m) => {\n                    match query_manager.connect().upsert_http_request(m, &UpdateSource::Plugin) {\n                        Ok(model) => HttpRequest(model),\n                        Err(err) => {\n                            return InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to upsert HTTP request: {err}\"),\n                            });\n                        }\n                    }\n                }\n                GrpcRequest(m) => {\n                    match query_manager.connect().upsert_grpc_request(m, &UpdateSource::Plugin) {\n                        Ok(model) => GrpcRequest(model),\n                        Err(err) => {\n                            return InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to upsert gRPC request: {err}\"),\n                            });\n                        }\n                    }\n                }\n                WebsocketRequest(m) => {\n                    match query_manager.connect().upsert_websocket_request(m, &UpdateSource::Plugin)\n                    {\n                        Ok(model) => WebsocketRequest(model),\n                        Err(err) => {\n                            return InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to upsert WebSocket request: {err}\"),\n                            });\n                        }\n                    }\n                }\n                Folder(m) => {\n                    match query_manager.connect().upsert_folder(m, &UpdateSource::Plugin) {\n                        Ok(model) => Folder(model),\n                        Err(err) => {\n                            return InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to upsert folder: {err}\"),\n                            });\n                        }\n                    }\n                }\n                Environment(m) => {\n                    match query_manager.connect().upsert_environment(m, &UpdateSource::Plugin) {\n                        Ok(model) => Environment(model),\n                        Err(err) => {\n                            return InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to upsert environment: {err}\"),\n                            });\n                        }\n                    }\n                }\n                Workspace(m) => {\n                    match query_manager.connect().upsert_workspace(m, &UpdateSource::Plugin) {\n                        Ok(model) => Workspace(model),\n                        Err(err) => {\n                            return InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to upsert workspace: {err}\"),\n                            });\n                        }\n                    }\n                }\n                _ => {\n                    return InternalEventPayload::ErrorResponse(ErrorResponse {\n                        error: \"Upsert not supported for this model type\".to_string(),\n                    });\n                }\n            };\n\n            InternalEventPayload::UpsertModelResponse(UpsertModelResponse { model })\n        }\n        SharedRequest::DeleteModel(req) => {\n            let model = match req.model.as_str() {\n                \"http_request\" => {\n                    match query_manager\n                        .connect()\n                        .delete_http_request_by_id(&req.id, &UpdateSource::Plugin)\n                    {\n                        Ok(model) => AnyModel::HttpRequest(model),\n                        Err(err) => {\n                            return InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to delete HTTP request: {err}\"),\n                            });\n                        }\n                    }\n                }\n                \"grpc_request\" => {\n                    match query_manager\n                        .connect()\n                        .delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin)\n                    {\n                        Ok(model) => AnyModel::GrpcRequest(model),\n                        Err(err) => {\n                            return InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to delete gRPC request: {err}\"),\n                            });\n                        }\n                    }\n                }\n                \"websocket_request\" => {\n                    match query_manager\n                        .connect()\n                        .delete_websocket_request_by_id(&req.id, &UpdateSource::Plugin)\n                    {\n                        Ok(model) => AnyModel::WebsocketRequest(model),\n                        Err(err) => {\n                            return InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to delete WebSocket request: {err}\"),\n                            });\n                        }\n                    }\n                }\n                \"folder\" => match query_manager\n                    .connect()\n                    .delete_folder_by_id(&req.id, &UpdateSource::Plugin)\n                {\n                    Ok(model) => AnyModel::Folder(model),\n                    Err(err) => {\n                        return InternalEventPayload::ErrorResponse(ErrorResponse {\n                            error: format!(\"Failed to delete folder: {err}\"),\n                        });\n                    }\n                },\n                \"environment\" => {\n                    match query_manager\n                        .connect()\n                        .delete_environment_by_id(&req.id, &UpdateSource::Plugin)\n                    {\n                        Ok(model) => AnyModel::Environment(model),\n                        Err(err) => {\n                            return InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to delete environment: {err}\"),\n                            });\n                        }\n                    }\n                }\n                _ => {\n                    return InternalEventPayload::ErrorResponse(ErrorResponse {\n                        error: \"Delete not supported for this model type\".to_string(),\n                    });\n                }\n            };\n\n            InternalEventPayload::DeleteModelResponse(DeleteModelResponse { model })\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use tempfile::TempDir;\n    use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace};\n    use yaak_models::util::UpdateSource;\n\n    fn seed_query_manager() -> (QueryManager, TempDir) {\n        let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n        let db_path = temp_dir.path().join(\"db.sqlite\");\n        let blob_path = temp_dir.path().join(\"blobs.sqlite\");\n        let (query_manager, _blob_manager, _rx) =\n            yaak_models::init_standalone(&db_path, &blob_path).expect(\"Failed to initialize DB\");\n\n        query_manager\n            .connect()\n            .upsert_workspace(\n                &Workspace {\n                    id: \"wk_test\".to_string(),\n                    name: \"Workspace\".to_string(),\n                    ..Default::default()\n                },\n                &UpdateSource::Sync,\n            )\n            .expect(\"Failed to seed workspace\");\n\n        query_manager\n            .connect()\n            .upsert_folder(\n                &Folder {\n                    id: \"fl_test\".to_string(),\n                    workspace_id: \"wk_test\".to_string(),\n                    name: \"Folder\".to_string(),\n                    ..Default::default()\n                },\n                &UpdateSource::Sync,\n            )\n            .expect(\"Failed to seed folder\");\n\n        query_manager\n            .connect()\n            .upsert_http_request(\n                &HttpRequest {\n                    id: \"rq_test\".to_string(),\n                    workspace_id: \"wk_test\".to_string(),\n                    folder_id: Some(\"fl_test\".to_string()),\n                    name: \"Request\".to_string(),\n                    method: \"GET\".to_string(),\n                    url: \"https://example.com\".to_string(),\n                    ..Default::default()\n                },\n                &UpdateSource::Sync,\n            )\n            .expect(\"Failed to seed request\");\n\n        (query_manager, temp_dir)\n    }\n\n    #[test]\n    fn list_requests_requires_workspace_when_folder_missing() {\n        let (query_manager, _temp_dir) = seed_query_manager();\n        let payload = InternalEventPayload::ListHttpRequestsRequest(\n            yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },\n        );\n        let result = handle_shared_plugin_event(\n            &query_manager,\n            &payload,\n            SharedPluginEventContext { plugin_name: \"@yaak/test\", workspace_id: None },\n        );\n\n        assert!(matches!(\n            result,\n            GroupedPluginEvent::Handled(Some(InternalEventPayload::ErrorResponse(_)))\n        ));\n    }\n\n    #[test]\n    fn list_requests_by_workspace_and_folder() {\n        let (query_manager, _temp_dir) = seed_query_manager();\n\n        let by_workspace_payload = InternalEventPayload::ListHttpRequestsRequest(\n            yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },\n        );\n        let by_workspace = handle_shared_plugin_event(\n            &query_manager,\n            &by_workspace_payload,\n            SharedPluginEventContext { plugin_name: \"@yaak/test\", workspace_id: Some(\"wk_test\") },\n        );\n        match by_workspace {\n            GroupedPluginEvent::Handled(Some(InternalEventPayload::ListHttpRequestsResponse(\n                resp,\n            ))) => {\n                assert_eq!(resp.http_requests.len(), 1);\n            }\n            other => panic!(\"unexpected workspace response: {other:?}\"),\n        }\n\n        let by_folder_payload = InternalEventPayload::ListHttpRequestsRequest(\n            yaak_plugins::events::ListHttpRequestsRequest {\n                folder_id: Some(\"fl_test\".to_string()),\n            },\n        );\n        let by_folder = handle_shared_plugin_event(\n            &query_manager,\n            &by_folder_payload,\n            SharedPluginEventContext { plugin_name: \"@yaak/test\", workspace_id: None },\n        );\n        match by_folder {\n            GroupedPluginEvent::Handled(Some(InternalEventPayload::ListHttpRequestsResponse(\n                resp,\n            ))) => {\n                assert_eq!(resp.http_requests.len(), 1);\n            }\n            other => panic!(\"unexpected folder response: {other:?}\"),\n        }\n    }\n\n    #[test]\n    fn find_http_responses_is_shared_handled() {\n        let (query_manager, _temp_dir) = seed_query_manager();\n        let payload = InternalEventPayload::FindHttpResponsesRequest(FindHttpResponsesRequest {\n            request_id: \"rq_test\".to_string(),\n            limit: Some(1),\n        });\n\n        let result = handle_shared_plugin_event(\n            &query_manager,\n            &payload,\n            SharedPluginEventContext { plugin_name: \"@yaak/test\", workspace_id: Some(\"wk_test\") },\n        );\n\n        match result {\n            GroupedPluginEvent::Handled(Some(InternalEventPayload::FindHttpResponsesResponse(\n                resp,\n            ))) => {\n                assert!(resp.http_responses.is_empty());\n            }\n            other => panic!(\"unexpected find responses result: {other:?}\"),\n        }\n    }\n\n    #[test]\n    fn upsert_and_delete_model_are_shared_handled() {\n        let (query_manager, _temp_dir) = seed_query_manager();\n\n        let existing = query_manager\n            .connect()\n            .get_http_request(\"rq_test\")\n            .expect(\"Failed to load seeded request\");\n        let upsert_payload = InternalEventPayload::UpsertModelRequest(UpsertModelRequest {\n            model: AnyModel::HttpRequest(HttpRequest {\n                name: \"Request Updated\".to_string(),\n                ..existing\n            }),\n        });\n\n        let upsert_result = handle_shared_plugin_event(\n            &query_manager,\n            &upsert_payload,\n            SharedPluginEventContext { plugin_name: \"@yaak/test\", workspace_id: Some(\"wk_test\") },\n        );\n        match upsert_result {\n            GroupedPluginEvent::Handled(Some(InternalEventPayload::UpsertModelResponse(resp))) => {\n                match resp.model {\n                    AnyModel::HttpRequest(r) => assert_eq!(r.name, \"Request Updated\"),\n                    other => panic!(\"unexpected upsert model type: {other:?}\"),\n                }\n            }\n            other => panic!(\"unexpected upsert result: {other:?}\"),\n        }\n\n        let delete_payload = InternalEventPayload::DeleteModelRequest(DeleteModelRequest {\n            model: \"http_request\".to_string(),\n            id: \"rq_test\".to_string(),\n        });\n        let delete_result = handle_shared_plugin_event(\n            &query_manager,\n            &delete_payload,\n            SharedPluginEventContext { plugin_name: \"@yaak/test\", workspace_id: Some(\"wk_test\") },\n        );\n        match delete_result {\n            GroupedPluginEvent::Handled(Some(InternalEventPayload::DeleteModelResponse(resp))) => {\n                match resp.model {\n                    AnyModel::HttpRequest(r) => assert_eq!(r.id, \"rq_test\"),\n                    other => panic!(\"unexpected delete model type: {other:?}\"),\n                }\n            }\n            other => panic!(\"unexpected delete result: {other:?}\"),\n        }\n    }\n\n    #[test]\n    fn host_request_classification_works() {\n        let (query_manager, _temp_dir) = seed_query_manager();\n        let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest {\n            label: \"main\".to_string(),\n        });\n        let result = handle_shared_plugin_event(\n            &query_manager,\n            &payload,\n            SharedPluginEventContext { plugin_name: \"@yaak/test\", workspace_id: None },\n        );\n\n        match result {\n            GroupedPluginEvent::ToHandle(HostRequest::WindowInfo(req)) => {\n                assert_eq!(req.label, \"main\")\n            }\n            other => panic!(\"unexpected host classification: {other:?}\"),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/yaak/src/render.rs",
    "content": "use log::info;\nuse serde_json::Value;\nuse std::collections::BTreeMap;\nuse yaak_http::path_placeholders::apply_path_placeholders;\nuse yaak_models::models::{Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter};\nuse yaak_models::render::make_vars_hashmap;\nuse yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};\n\npub async fn render_http_request<T: TemplateCallback>(\n    request: &HttpRequest,\n    environment_chain: Vec<Environment>,\n    callback: &T,\n    options: &RenderOptions,\n) -> yaak_templates::error::Result<HttpRequest> {\n    let vars = &make_vars_hashmap(environment_chain);\n\n    let mut url_parameters = Vec::new();\n    for parameter in request.url_parameters.clone() {\n        if !parameter.enabled {\n            continue;\n        }\n\n        url_parameters.push(HttpUrlParameter {\n            enabled: parameter.enabled,\n            name: parse_and_render(parameter.name.as_str(), vars, callback, options).await?,\n            value: parse_and_render(parameter.value.as_str(), vars, callback, options).await?,\n            id: parameter.id,\n        })\n    }\n\n    let mut headers = Vec::new();\n    for header in request.headers.clone() {\n        if !header.enabled {\n            continue;\n        }\n\n        headers.push(HttpRequestHeader {\n            enabled: header.enabled,\n            name: parse_and_render(header.name.as_str(), vars, callback, options).await?,\n            value: parse_and_render(header.value.as_str(), vars, callback, options).await?,\n            id: header.id,\n        })\n    }\n\n    let mut body = BTreeMap::new();\n    for (key, value) in request.body.clone() {\n        let value = if key == \"form\" { strip_disabled_form_entries(value) } else { value };\n        body.insert(key, render_json_value_raw(value, vars, callback, options).await?);\n    }\n\n    let authentication = {\n        let mut disabled = false;\n        let mut auth = BTreeMap::new();\n\n        match request.authentication.get(\"disabled\") {\n            Some(Value::Bool(true)) => {\n                disabled = true;\n            }\n            Some(Value::String(template)) => {\n                disabled = parse_and_render(template.as_str(), vars, callback, options)\n                    .await\n                    .unwrap_or_default()\n                    .is_empty();\n                info!(\n                    \"Rendering authentication.disabled as a template: {disabled} from \\\"{template}\\\"\"\n                );\n            }\n            _ => {}\n        }\n\n        if disabled {\n            auth.insert(\"disabled\".to_string(), Value::Bool(true));\n        } else {\n            for (key, value) in request.authentication.clone() {\n                if key == \"disabled\" {\n                    auth.insert(key, Value::Bool(false));\n                } else {\n                    auth.insert(key, render_json_value_raw(value, vars, callback, options).await?);\n                }\n            }\n        }\n\n        auth\n    };\n\n    let url = parse_and_render(request.url.clone().as_str(), vars, callback, options).await?;\n    let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);\n\n    Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..request.to_owned() })\n}\n\npub async fn render_grpc_request<T: TemplateCallback>(\n    r: &GrpcRequest,\n    environment_chain: Vec<Environment>,\n    cb: &T,\n    opt: &RenderOptions,\n) -> yaak_templates::error::Result<GrpcRequest> {\n    let vars = &make_vars_hashmap(environment_chain);\n\n    let mut metadata = Vec::new();\n    for p in r.metadata.clone() {\n        if !p.enabled {\n            continue;\n        }\n        metadata.push(HttpRequestHeader {\n            enabled: p.enabled,\n            name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,\n            value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,\n            id: p.id,\n        })\n    }\n\n    let authentication = {\n        let mut disabled = false;\n        let mut auth = BTreeMap::new();\n        match r.authentication.get(\"disabled\") {\n            Some(Value::Bool(true)) => {\n                disabled = true;\n            }\n            Some(Value::String(tmpl)) => {\n                disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)\n                    .await\n                    .unwrap_or_default()\n                    .is_empty();\n                info!(\n                    \"Rendering authentication.disabled as a template: {disabled} from \\\"{tmpl}\\\"\"\n                );\n            }\n            _ => {}\n        }\n        if disabled {\n            auth.insert(\"disabled\".to_string(), Value::Bool(true));\n        } else {\n            for (k, v) in r.authentication.clone() {\n                if k == \"disabled\" {\n                    auth.insert(k, Value::Bool(false));\n                } else {\n                    auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);\n                }\n            }\n        }\n        auth\n    };\n\n    let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?;\n\n    Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() })\n}\n\nfn strip_disabled_form_entries(v: Value) -> Value {\n    match v {\n        Value::Array(items) => Value::Array(\n            items\n                .into_iter()\n                .filter(|item| item.get(\"enabled\").and_then(|e| e.as_bool()).unwrap_or(true))\n                .collect(),\n        ),\n        v => v,\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use serde_json::json;\n\n    #[test]\n    fn test_strip_disabled_form_entries() {\n        let input = json!([\n            {\"enabled\": true, \"name\": \"foo\", \"value\": \"bar\"},\n            {\"enabled\": false, \"name\": \"disabled\", \"value\": \"gone\"},\n            {\"enabled\": true, \"name\": \"baz\", \"value\": \"qux\"},\n        ]);\n        let result = strip_disabled_form_entries(input);\n        assert_eq!(\n            result,\n            json!([\n                {\"enabled\": true, \"name\": \"foo\", \"value\": \"bar\"},\n                {\"enabled\": true, \"name\": \"baz\", \"value\": \"qux\"},\n            ])\n        );\n    }\n\n    #[test]\n    fn test_strip_disabled_form_entries_all_disabled() {\n        let input = json!([\n            {\"enabled\": false, \"name\": \"a\", \"value\": \"b\"},\n            {\"enabled\": false, \"name\": \"c\", \"value\": \"d\"},\n        ]);\n        let result = strip_disabled_form_entries(input);\n        assert_eq!(result, json!([]));\n    }\n\n    #[test]\n    fn test_strip_disabled_form_entries_missing_enabled_defaults_to_kept() {\n        let input = json!([\n            {\"name\": \"no_enabled_field\", \"value\": \"kept\"},\n            {\"enabled\": false, \"name\": \"disabled\", \"value\": \"gone\"},\n        ]);\n        let result = strip_disabled_form_entries(input);\n        assert_eq!(\n            result,\n            json!([\n                {\"name\": \"no_enabled_field\", \"value\": \"kept\"},\n            ])\n        );\n    }\n\n    #[test]\n    fn test_strip_disabled_form_entries_non_array_passthrough() {\n        let input = json!(\"just a string\");\n        let result = strip_disabled_form_entries(input.clone());\n        assert_eq!(result, input);\n    }\n}\n"
  },
  {
    "path": "crates/yaak/src/send.rs",
    "content": "use crate::render::render_http_request;\nuse async_trait::async_trait;\nuse log::warn;\nuse std::path::{Path, PathBuf};\nuse std::sync::Arc;\nuse std::sync::atomic::{AtomicI32, Ordering};\nuse std::time::Instant;\nuse thiserror::Error;\nuse tokio::fs::File;\nuse tokio::io::{AsyncReadExt, AsyncWriteExt};\nuse tokio::sync::mpsc;\nuse tokio::sync::watch;\nuse yaak_crypto::manager::EncryptionManager;\nuse yaak_http::client::{\n    HttpConnectionOptions, HttpConnectionProxySetting, HttpConnectionProxySettingAuth,\n};\nuse yaak_http::cookies::CookieStore;\nuse yaak_http::manager::HttpConnectionManager;\nuse yaak_http::sender::{HttpResponseEvent as SenderHttpResponseEvent, ReqwestSender};\nuse yaak_http::tee_reader::TeeReader;\nuse yaak_http::transaction::HttpTransaction;\nuse yaak_http::types::{\n    SendableBody, SendableHttpRequest, SendableHttpRequestOptions, append_query_params,\n};\nuse yaak_models::blob_manager::{BlobManager, BodyChunk};\nuse yaak_models::models::{\n    ClientCertificate, CookieJar, DnsOverride, Environment, HttpRequest, HttpResponse,\n    HttpResponseEvent, HttpResponseHeader, HttpResponseState, ProxySetting, ProxySettingAuth,\n};\nuse yaak_models::query_manager::QueryManager;\nuse yaak_models::util::{UpdateSource, generate_prefixed_id};\nuse yaak_plugins::events::{\n    CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose,\n};\nuse yaak_plugins::manager::PluginManager;\nuse yaak_plugins::template_callback::PluginTemplateCallback;\nuse yaak_templates::{RenderOptions, TemplateCallback};\nuse yaak_tls::find_client_certificate;\n\nconst HTTP_EVENT_CHANNEL_CAPACITY: usize = 100;\nconst REQUEST_BODY_CHUNK_SIZE: usize = 1024 * 1024;\nconst RESPONSE_PROGRESS_UPDATE_INTERVAL_MS: u128 = 100;\n\n#[derive(Debug, Error)]\npub enum SendHttpRequestError {\n    #[error(\"Failed to load request: {0}\")]\n    LoadRequest(#[source] yaak_models::error::Error),\n\n    #[error(\"Failed to load workspace: {0}\")]\n    LoadWorkspace(#[source] yaak_models::error::Error),\n\n    #[error(\"Failed to resolve environments: {0}\")]\n    ResolveEnvironments(#[source] yaak_models::error::Error),\n\n    #[error(\"Failed to resolve inherited request settings: {0}\")]\n    ResolveRequestInheritance(#[source] yaak_models::error::Error),\n\n    #[error(\"Failed to load cookie jar: {0}\")]\n    LoadCookieJar(#[source] yaak_models::error::Error),\n\n    #[error(\"Failed to persist cookie jar: {0}\")]\n    PersistCookieJar(#[source] yaak_models::error::Error),\n\n    #[error(\"Failed to render request templates: {0}\")]\n    RenderRequest(#[source] yaak_templates::error::Error),\n\n    #[error(\"Failed to prepare request before send: {0}\")]\n    PrepareSendableRequest(String),\n\n    #[error(\"Failed to persist response metadata: {0}\")]\n    PersistResponse(#[source] yaak_models::error::Error),\n\n    #[error(\"Failed to create HTTP client: {0}\")]\n    CreateHttpClient(#[source] yaak_http::error::Error),\n\n    #[error(\"Failed to build sendable request: {0}\")]\n    BuildSendableRequest(#[source] yaak_http::error::Error),\n\n    #[error(\"Failed to send request: {0}\")]\n    SendRequest(#[source] yaak_http::error::Error),\n\n    #[error(\"Failed to read response body: {0}\")]\n    ReadResponseBody(#[source] yaak_http::error::Error),\n\n    #[error(\"Failed to create response directory {path:?}: {source}\")]\n    CreateResponseDirectory {\n        path: PathBuf,\n        #[source]\n        source: std::io::Error,\n    },\n\n    #[error(\"Failed to write response body to {path:?}: {source}\")]\n    WriteResponseBody {\n        path: PathBuf,\n        #[source]\n        source: std::io::Error,\n    },\n}\n\npub type Result<T> = std::result::Result<T, SendHttpRequestError>;\n\n#[async_trait]\npub trait PrepareSendableRequest: Send + Sync {\n    async fn prepare_sendable_request(\n        &self,\n        rendered_request: &HttpRequest,\n        auth_context_id: &str,\n        sendable_request: &mut SendableHttpRequest,\n    ) -> std::result::Result<(), String>;\n}\n\n#[async_trait]\npub trait SendRequestExecutor: Send + Sync {\n    async fn send(\n        &self,\n        sendable_request: SendableHttpRequest,\n        event_tx: mpsc::Sender<SenderHttpResponseEvent>,\n        cookie_store: Option<CookieStore>,\n    ) -> yaak_http::error::Result<yaak_http::sender::HttpResponse>;\n}\n\nstruct DefaultSendRequestExecutor;\n\n#[async_trait]\nimpl SendRequestExecutor for DefaultSendRequestExecutor {\n    async fn send(\n        &self,\n        sendable_request: SendableHttpRequest,\n        event_tx: mpsc::Sender<SenderHttpResponseEvent>,\n        cookie_store: Option<CookieStore>,\n    ) -> yaak_http::error::Result<yaak_http::sender::HttpResponse> {\n        let sender = ReqwestSender::new()?;\n        let transaction = match cookie_store {\n            Some(store) => HttpTransaction::with_cookie_store(sender, store),\n            None => HttpTransaction::new(sender),\n        };\n        let (_cancel_tx, cancel_rx) = watch::channel(false);\n        transaction.execute_with_cancellation(sendable_request, cancel_rx, event_tx).await\n    }\n}\n\nstruct PluginPrepareSendableRequest {\n    plugin_manager: Arc<PluginManager>,\n    plugin_context: PluginContext,\n    cancelled_rx: Option<watch::Receiver<bool>>,\n}\n\n#[async_trait]\nimpl PrepareSendableRequest for PluginPrepareSendableRequest {\n    async fn prepare_sendable_request(\n        &self,\n        rendered_request: &HttpRequest,\n        auth_context_id: &str,\n        sendable_request: &mut SendableHttpRequest,\n    ) -> std::result::Result<(), String> {\n        if let Some(cancelled_rx) = &self.cancelled_rx {\n            let mut cancelled_rx = cancelled_rx.clone();\n            tokio::select! {\n                result = apply_plugin_authentication(\n                    sendable_request,\n                    rendered_request,\n                    auth_context_id,\n                    &self.plugin_manager,\n                    &self.plugin_context,\n                ) => result,\n                _ = cancelled_rx.changed() => Err(\"Request canceled\".to_string()),\n            }\n        } else {\n            apply_plugin_authentication(\n                sendable_request,\n                rendered_request,\n                auth_context_id,\n                &self.plugin_manager,\n                &self.plugin_context,\n            )\n            .await\n        }\n    }\n}\n\nstruct ConnectionManagerSendRequestExecutor<'a> {\n    connection_manager: &'a HttpConnectionManager,\n    plugin_context_id: String,\n    query_manager: QueryManager,\n    workspace_id: String,\n    cancelled_rx: Option<watch::Receiver<bool>>,\n}\n\n#[async_trait]\nimpl SendRequestExecutor for ConnectionManagerSendRequestExecutor<'_> {\n    async fn send(\n        &self,\n        sendable_request: SendableHttpRequest,\n        event_tx: mpsc::Sender<SenderHttpResponseEvent>,\n        cookie_store: Option<CookieStore>,\n    ) -> yaak_http::error::Result<yaak_http::sender::HttpResponse> {\n        let runtime_config =\n            resolve_http_send_runtime_config(&self.query_manager, &self.workspace_id)\n                .map_err(|e| yaak_http::error::Error::RequestError(e.to_string()))?;\n        let client_certificate =\n            find_client_certificate(&sendable_request.url, &runtime_config.client_certificates);\n        let cached_client = self\n            .connection_manager\n            .get_client(&HttpConnectionOptions {\n                id: self.plugin_context_id.clone(),\n                validate_certificates: runtime_config.validate_certificates,\n                proxy: runtime_config.proxy,\n                client_certificate,\n                dns_overrides: runtime_config.dns_overrides,\n            })\n            .await?;\n\n        cached_client.resolver.set_event_sender(Some(event_tx.clone())).await;\n\n        let sender = ReqwestSender::with_client(cached_client.client);\n        let transaction = match cookie_store {\n            Some(cs) => HttpTransaction::with_cookie_store(sender, cs),\n            None => HttpTransaction::new(sender),\n        };\n\n        let result = if let Some(cancelled_rx) = self.cancelled_rx.clone() {\n            transaction.execute_with_cancellation(sendable_request, cancelled_rx, event_tx).await\n        } else {\n            let (_cancel_tx, cancel_rx) = watch::channel(false);\n            transaction.execute_with_cancellation(sendable_request, cancel_rx, event_tx).await\n        };\n        cached_client.resolver.set_event_sender(None).await;\n        result\n    }\n}\n\npub struct SendHttpRequestByIdParams<'a, T: TemplateCallback> {\n    pub query_manager: &'a QueryManager,\n    pub blob_manager: &'a BlobManager,\n    pub request_id: &'a str,\n    pub environment_id: Option<&'a str>,\n    pub template_callback: &'a T,\n    pub update_source: UpdateSource,\n    pub cookie_jar_id: Option<String>,\n    pub response_dir: &'a Path,\n    pub emit_events_to: Option<mpsc::Sender<SenderHttpResponseEvent>>,\n    pub emit_response_body_chunks_to: Option<mpsc::UnboundedSender<Vec<u8>>>,\n    pub cancelled_rx: Option<watch::Receiver<bool>>,\n    pub prepare_sendable_request: Option<&'a dyn PrepareSendableRequest>,\n    pub executor: Option<&'a dyn SendRequestExecutor>,\n}\n\npub struct SendHttpRequestParams<'a, T: TemplateCallback> {\n    pub query_manager: &'a QueryManager,\n    pub blob_manager: &'a BlobManager,\n    pub request: HttpRequest,\n    pub environment_id: Option<&'a str>,\n    pub template_callback: &'a T,\n    pub send_options: Option<SendableHttpRequestOptions>,\n    pub update_source: UpdateSource,\n    pub cookie_jar_id: Option<String>,\n    pub response_dir: &'a Path,\n    pub emit_events_to: Option<mpsc::Sender<SenderHttpResponseEvent>>,\n    pub emit_response_body_chunks_to: Option<mpsc::UnboundedSender<Vec<u8>>>,\n    pub cancelled_rx: Option<watch::Receiver<bool>>,\n    pub auth_context_id: Option<String>,\n    pub existing_response: Option<HttpResponse>,\n    pub prepare_sendable_request: Option<&'a dyn PrepareSendableRequest>,\n    pub executor: Option<&'a dyn SendRequestExecutor>,\n}\n\npub struct SendHttpRequestWithPluginsParams<'a> {\n    pub query_manager: &'a QueryManager,\n    pub blob_manager: &'a BlobManager,\n    pub request: HttpRequest,\n    pub environment_id: Option<&'a str>,\n    pub update_source: UpdateSource,\n    pub cookie_jar_id: Option<String>,\n    pub response_dir: &'a Path,\n    pub emit_events_to: Option<mpsc::Sender<SenderHttpResponseEvent>>,\n    pub emit_response_body_chunks_to: Option<mpsc::UnboundedSender<Vec<u8>>>,\n    pub existing_response: Option<HttpResponse>,\n    pub plugin_manager: Arc<PluginManager>,\n    pub encryption_manager: Arc<EncryptionManager>,\n    pub plugin_context: &'a PluginContext,\n    pub cancelled_rx: Option<watch::Receiver<bool>>,\n    pub connection_manager: Option<&'a HttpConnectionManager>,\n}\n\npub struct SendHttpRequestByIdWithPluginsParams<'a> {\n    pub query_manager: &'a QueryManager,\n    pub blob_manager: &'a BlobManager,\n    pub request_id: &'a str,\n    pub environment_id: Option<&'a str>,\n    pub update_source: UpdateSource,\n    pub cookie_jar_id: Option<String>,\n    pub response_dir: &'a Path,\n    pub emit_events_to: Option<mpsc::Sender<SenderHttpResponseEvent>>,\n    pub emit_response_body_chunks_to: Option<mpsc::UnboundedSender<Vec<u8>>>,\n    pub plugin_manager: Arc<PluginManager>,\n    pub encryption_manager: Arc<EncryptionManager>,\n    pub plugin_context: &'a PluginContext,\n    pub cancelled_rx: Option<watch::Receiver<bool>>,\n    pub connection_manager: Option<&'a HttpConnectionManager>,\n}\n\npub struct SendHttpRequestResult {\n    pub rendered_request: HttpRequest,\n    pub response: HttpResponse,\n    pub response_body: Vec<u8>,\n}\n\npub struct HttpSendRuntimeConfig {\n    pub send_options: SendableHttpRequestOptions,\n    pub validate_certificates: bool,\n    pub proxy: HttpConnectionProxySetting,\n    pub dns_overrides: Vec<DnsOverride>,\n    pub client_certificates: Vec<ClientCertificate>,\n}\n\npub fn resolve_http_send_runtime_config(\n    query_manager: &QueryManager,\n    workspace_id: &str,\n) -> Result<HttpSendRuntimeConfig> {\n    let db = query_manager.connect();\n    let workspace = db.get_workspace(workspace_id).map_err(SendHttpRequestError::LoadWorkspace)?;\n    let settings = db.get_settings();\n\n    Ok(HttpSendRuntimeConfig {\n        send_options: SendableHttpRequestOptions {\n            follow_redirects: workspace.setting_follow_redirects,\n            timeout: if workspace.setting_request_timeout > 0 {\n                Some(std::time::Duration::from_millis(\n                    workspace.setting_request_timeout.unsigned_abs() as u64,\n                ))\n            } else {\n                None\n            },\n        },\n        validate_certificates: workspace.setting_validate_certificates,\n        proxy: proxy_setting_from_settings(settings.proxy),\n        dns_overrides: workspace.setting_dns_overrides,\n        client_certificates: settings.client_certificates,\n    })\n}\n\npub async fn send_http_request_by_id_with_plugins(\n    params: SendHttpRequestByIdWithPluginsParams<'_>,\n) -> Result<SendHttpRequestResult> {\n    let request = params\n        .query_manager\n        .connect()\n        .get_http_request(params.request_id)\n        .map_err(SendHttpRequestError::LoadRequest)?;\n\n    send_http_request_with_plugins(SendHttpRequestWithPluginsParams {\n        query_manager: params.query_manager,\n        blob_manager: params.blob_manager,\n        request,\n        environment_id: params.environment_id,\n        update_source: params.update_source,\n        cookie_jar_id: params.cookie_jar_id,\n        response_dir: params.response_dir,\n        emit_events_to: params.emit_events_to,\n        emit_response_body_chunks_to: params.emit_response_body_chunks_to,\n        existing_response: None,\n        plugin_manager: params.plugin_manager,\n        encryption_manager: params.encryption_manager,\n        plugin_context: params.plugin_context,\n        cancelled_rx: params.cancelled_rx,\n        connection_manager: params.connection_manager,\n    })\n    .await\n}\n\npub async fn send_http_request_with_plugins(\n    params: SendHttpRequestWithPluginsParams<'_>,\n) -> Result<SendHttpRequestResult> {\n    let template_callback = PluginTemplateCallback::new(\n        params.plugin_manager.clone(),\n        params.encryption_manager.clone(),\n        params.plugin_context,\n        RenderPurpose::Send,\n    );\n    let auth_hook = PluginPrepareSendableRequest {\n        plugin_manager: params.plugin_manager,\n        plugin_context: params.plugin_context.clone(),\n        cancelled_rx: params.cancelled_rx.clone(),\n    };\n    let executor =\n        params.connection_manager.map(|connection_manager| ConnectionManagerSendRequestExecutor {\n            connection_manager,\n            plugin_context_id: params.plugin_context.id.clone(),\n            query_manager: params.query_manager.clone(),\n            workspace_id: params.request.workspace_id.clone(),\n            cancelled_rx: params.cancelled_rx.clone(),\n        });\n\n    send_http_request(SendHttpRequestParams {\n        query_manager: params.query_manager,\n        blob_manager: params.blob_manager,\n        request: params.request,\n        environment_id: params.environment_id,\n        template_callback: &template_callback,\n        send_options: None,\n        update_source: params.update_source,\n        cookie_jar_id: params.cookie_jar_id,\n        response_dir: params.response_dir,\n        emit_events_to: params.emit_events_to,\n        emit_response_body_chunks_to: params.emit_response_body_chunks_to,\n        cancelled_rx: params.cancelled_rx,\n        auth_context_id: None,\n        existing_response: params.existing_response,\n        prepare_sendable_request: Some(&auth_hook),\n        executor: executor.as_ref().map(|e| e as &dyn SendRequestExecutor),\n    })\n    .await\n}\n\npub async fn send_http_request_by_id<T: TemplateCallback>(\n    params: SendHttpRequestByIdParams<'_, T>,\n) -> Result<SendHttpRequestResult> {\n    let request = params\n        .query_manager\n        .connect()\n        .get_http_request(params.request_id)\n        .map_err(SendHttpRequestError::LoadRequest)?;\n    let (request, auth_context_id) = resolve_inherited_request(params.query_manager, &request)?;\n\n    send_http_request(SendHttpRequestParams {\n        query_manager: params.query_manager,\n        blob_manager: params.blob_manager,\n        request,\n        environment_id: params.environment_id,\n        template_callback: params.template_callback,\n        send_options: None,\n        update_source: params.update_source,\n        cookie_jar_id: params.cookie_jar_id,\n        response_dir: params.response_dir,\n        emit_events_to: params.emit_events_to,\n        emit_response_body_chunks_to: params.emit_response_body_chunks_to,\n        cancelled_rx: params.cancelled_rx,\n        existing_response: None,\n        prepare_sendable_request: params.prepare_sendable_request,\n        executor: params.executor,\n        auth_context_id: Some(auth_context_id),\n    })\n    .await\n}\n\npub async fn send_http_request<T: TemplateCallback>(\n    params: SendHttpRequestParams<'_, T>,\n) -> Result<SendHttpRequestResult> {\n    let environment_chain =\n        resolve_environment_chain(params.query_manager, &params.request, params.environment_id)?;\n    let (resolved_request, auth_context_id) =\n        if let Some(auth_context_id) = params.auth_context_id.clone() {\n            (params.request.clone(), auth_context_id)\n        } else {\n            resolve_inherited_request(params.query_manager, &params.request)?\n        };\n    let runtime_config =\n        resolve_http_send_runtime_config(params.query_manager, &params.request.workspace_id)?;\n    let send_options = params.send_options.unwrap_or(runtime_config.send_options);\n    let mut cookie_jar = load_cookie_jar(params.query_manager, params.cookie_jar_id.as_deref())?;\n    let cookie_store =\n        cookie_jar.as_ref().map(|jar| CookieStore::from_cookies(jar.cookies.clone()));\n\n    let rendered_request = render_http_request(\n        &resolved_request,\n        environment_chain,\n        params.template_callback,\n        &RenderOptions::throw(),\n    )\n    .await\n    .map_err(SendHttpRequestError::RenderRequest)?;\n\n    let mut sendable_request =\n        SendableHttpRequest::from_http_request(&rendered_request, send_options)\n            .await\n            .map_err(SendHttpRequestError::BuildSendableRequest)?;\n\n    if let Some(hook) = params.prepare_sendable_request {\n        hook.prepare_sendable_request(&rendered_request, &auth_context_id, &mut sendable_request)\n            .await\n            .map_err(SendHttpRequestError::PrepareSendableRequest)?;\n    }\n\n    let request_content_length = sendable_body_length(sendable_request.body.as_ref());\n    let mut response = params.existing_response.unwrap_or_default();\n    response.request_id = params.request.id.clone();\n    response.workspace_id = params.request.workspace_id.clone();\n    response.request_content_length = request_content_length;\n    response.request_headers = sendable_request\n        .headers\n        .iter()\n        .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() })\n        .collect();\n    response.url = sendable_request.url.clone();\n    response.state = HttpResponseState::Initialized;\n    response.error = None;\n    response.content_length = None;\n    response.content_length_compressed = None;\n    response.body_path = None;\n    response.status = 0;\n    response.status_reason = None;\n    response.headers = Vec::new();\n    response.remote_addr = None;\n    response.version = None;\n    response.elapsed = 0;\n    response.elapsed_headers = 0;\n    response.elapsed_dns = 0;\n    let persist_response = !response.request_id.is_empty();\n    if persist_response {\n        response = params\n            .query_manager\n            .connect()\n            .upsert_http_response(&response, &params.update_source, params.blob_manager)\n            .map_err(SendHttpRequestError::PersistResponse)?;\n    } else if response.id.is_empty() {\n        response.id = generate_prefixed_id(\"rs\");\n    }\n\n    let request_body_id = format!(\"{}.request\", response.id);\n    let mut request_body_capture_task = None;\n    let mut request_body_capture_error = None;\n    if persist_response {\n        match sendable_request.body.as_mut() {\n            Some(SendableBody::Bytes(bytes)) => {\n                if let Err(err) = persist_request_body_bytes(\n                    params.blob_manager,\n                    &request_body_id,\n                    bytes.as_ref(),\n                ) {\n                    request_body_capture_error = Some(err);\n                }\n            }\n            Some(SendableBody::Stream { data, .. }) => {\n                let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();\n                let inner = std::mem::replace(data, Box::pin(tokio::io::empty()));\n                let tee_reader = TeeReader::new(inner, tx);\n                *data = Box::pin(tee_reader);\n                let blob_manager = params.blob_manager.clone();\n                let body_id = request_body_id.clone();\n                request_body_capture_task = Some(tokio::spawn(async move {\n                    persist_request_body_stream(blob_manager, body_id, rx).await\n                }));\n            }\n            None => {}\n        }\n    }\n\n    let (event_tx, mut event_rx) =\n        mpsc::channel::<SenderHttpResponseEvent>(HTTP_EVENT_CHANNEL_CAPACITY);\n    let event_query_manager = params.query_manager.clone();\n    let event_response_id = response.id.clone();\n    let event_workspace_id = params.request.workspace_id.clone();\n    let event_update_source = params.update_source.clone();\n    let emit_events_to = params.emit_events_to.clone();\n    let dns_elapsed = Arc::new(AtomicI32::new(0));\n    let event_dns_elapsed = dns_elapsed.clone();\n    let event_handle = tokio::spawn(async move {\n        while let Some(event) = event_rx.recv().await {\n            if let SenderHttpResponseEvent::DnsResolved { duration, .. } = &event {\n                event_dns_elapsed.store(u64_to_i32(*duration), Ordering::Relaxed);\n            }\n\n            if persist_response {\n                let db_event = HttpResponseEvent::new(\n                    &event_response_id,\n                    &event_workspace_id,\n                    event.clone().into(),\n                );\n                if let Err(err) = event_query_manager\n                    .connect()\n                    .upsert_http_response_event(&db_event, &event_update_source)\n                {\n                    warn!(\"Failed to persist HTTP response event: {}\", err);\n                }\n            }\n\n            if let Some(tx) = emit_events_to.as_ref() {\n                let _ = tx.try_send(event);\n            }\n        }\n    });\n\n    let default_executor = DefaultSendRequestExecutor;\n    let executor = params.executor.unwrap_or(&default_executor);\n    let started_at = Instant::now();\n    let request_started_url = sendable_request.url.clone();\n\n    let mut http_response = match executor\n        .send(sendable_request, event_tx, cookie_store.clone())\n        .await\n    {\n        Ok(response) => response,\n        Err(err) => {\n            persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_store.as_ref())?;\n            if persist_response {\n                let _ = persist_response_error(\n                    params.query_manager,\n                    params.blob_manager,\n                    &params.update_source,\n                    &response,\n                    started_at,\n                    err.to_string(),\n                    request_started_url,\n                );\n            }\n            if let Err(join_err) = event_handle.await {\n                warn!(\"Failed to join response event task: {}\", join_err);\n            }\n            if let Some(task) = request_body_capture_task.take() {\n                let _ = task.await;\n            }\n            return Err(SendHttpRequestError::SendRequest(err));\n        }\n    };\n\n    let headers_elapsed = duration_to_i32(started_at.elapsed());\n    std::fs::create_dir_all(params.response_dir).map_err(|source| {\n        SendHttpRequestError::CreateResponseDirectory {\n            path: params.response_dir.to_path_buf(),\n            source,\n        }\n    })?;\n    let body_path = params.response_dir.join(&response.id);\n    let connected_response = HttpResponse {\n        state: HttpResponseState::Connected,\n        elapsed_headers: headers_elapsed,\n        status: i32::from(http_response.status),\n        status_reason: http_response.status_reason.clone(),\n        url: http_response.url.clone(),\n        remote_addr: http_response.remote_addr.clone(),\n        version: http_response.version.clone(),\n        elapsed_dns: dns_elapsed.load(Ordering::Relaxed),\n        body_path: Some(body_path.to_string_lossy().to_string()),\n        content_length: http_response.content_length.map(u64_to_i32),\n        headers: http_response\n            .headers\n            .iter()\n            .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() })\n            .collect(),\n        request_headers: http_response\n            .request_headers\n            .iter()\n            .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() })\n            .collect(),\n        ..response\n    };\n    if persist_response {\n        response = params\n            .query_manager\n            .connect()\n            .upsert_http_response(&connected_response, &params.update_source, params.blob_manager)\n            .map_err(SendHttpRequestError::PersistResponse)?;\n    } else {\n        response = connected_response;\n    }\n\n    let mut file =\n        File::options().create(true).truncate(true).write(true).open(&body_path).await.map_err(\n            |source| SendHttpRequestError::WriteResponseBody { path: body_path.clone(), source },\n        )?;\n    let mut body_stream =\n        http_response.into_body_stream().map_err(SendHttpRequestError::ReadResponseBody)?;\n    let mut response_body = Vec::new();\n    let mut body_read_error = None;\n    let mut written_bytes: usize = 0;\n    let mut last_progress_update = started_at;\n    let mut cancelled_rx = params.cancelled_rx.clone();\n\n    loop {\n        let read_result = if let Some(cancelled_rx) = cancelled_rx.as_mut() {\n            if *cancelled_rx.borrow() {\n                break;\n            }\n\n            tokio::select! {\n                biased;\n                _ = cancelled_rx.changed() => {\n                    None\n                }\n                result = body_stream.read_buf(&mut response_body) => {\n                    Some(result)\n                }\n            }\n        } else {\n            Some(body_stream.read_buf(&mut response_body).await)\n        };\n\n        let Some(read_result) = read_result else {\n            break;\n        };\n\n        match read_result {\n            Ok(0) => break,\n            Ok(n) => {\n                written_bytes += n;\n                let start_idx = response_body.len() - n;\n                let chunk = &response_body[start_idx..];\n                file.write_all(chunk).await.map_err(|source| {\n                    SendHttpRequestError::WriteResponseBody { path: body_path.clone(), source }\n                })?;\n                file.flush().await.map_err(|source| SendHttpRequestError::WriteResponseBody {\n                    path: body_path.clone(),\n                    source,\n                })?;\n                if let Some(tx) = params.emit_response_body_chunks_to.as_ref() {\n                    let _ = tx.send(chunk.to_vec());\n                }\n\n                let now = Instant::now();\n                let should_update = now.duration_since(last_progress_update).as_millis()\n                    >= RESPONSE_PROGRESS_UPDATE_INTERVAL_MS;\n                if should_update {\n                    let elapsed = duration_to_i32(started_at.elapsed());\n                    let progress_response = HttpResponse {\n                        elapsed,\n                        content_length: Some(usize_to_i32(written_bytes)),\n                        elapsed_dns: dns_elapsed.load(Ordering::Relaxed),\n                        ..response.clone()\n                    };\n                    if persist_response {\n                        response = params\n                            .query_manager\n                            .connect()\n                            .upsert_http_response(\n                                &progress_response,\n                                &params.update_source,\n                                params.blob_manager,\n                            )\n                            .map_err(SendHttpRequestError::PersistResponse)?;\n                    } else {\n                        response = progress_response;\n                    }\n                    last_progress_update = now;\n                }\n            }\n            Err(err) => {\n                body_read_error = Some(SendHttpRequestError::ReadResponseBody(\n                    yaak_http::error::Error::BodyReadError(err.to_string()),\n                ));\n                break;\n            }\n        }\n    }\n\n    file.flush().await.map_err(|source| SendHttpRequestError::WriteResponseBody {\n        path: body_path.clone(),\n        source,\n    })?;\n    drop(body_stream);\n\n    if let Some(task) = request_body_capture_task.take() {\n        match task.await {\n            Ok(Ok(total)) => {\n                response.request_content_length = Some(usize_to_i32(total));\n            }\n            Ok(Err(err)) => request_body_capture_error = Some(err),\n            Err(err) => request_body_capture_error = Some(err.to_string()),\n        }\n    }\n\n    if let Some(err) = request_body_capture_error.take() {\n        response.error = Some(append_error_message(\n            response.error.take(),\n            format!(\"Request succeeded but failed to store request body: {err}\"),\n        ));\n    }\n\n    if let Err(join_err) = event_handle.await {\n        warn!(\"Failed to join response event task: {}\", join_err);\n    }\n\n    if let Some(err) = body_read_error {\n        if persist_response {\n            let _ = persist_response_error(\n                params.query_manager,\n                params.blob_manager,\n                &params.update_source,\n                &response,\n                started_at,\n                err.to_string(),\n                request_started_url,\n            );\n        }\n        persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_store.as_ref())?;\n        return Err(err);\n    }\n\n    let compressed_length = http_response.content_length.unwrap_or(written_bytes as u64);\n    let final_response = HttpResponse {\n        body_path: Some(body_path.to_string_lossy().to_string()),\n        content_length: Some(usize_to_i32(written_bytes)),\n        content_length_compressed: Some(u64_to_i32(compressed_length)),\n        elapsed: duration_to_i32(started_at.elapsed()),\n        elapsed_headers: headers_elapsed,\n        elapsed_dns: dns_elapsed.load(Ordering::Relaxed),\n        state: HttpResponseState::Closed,\n        ..response\n    };\n    if persist_response {\n        response = params\n            .query_manager\n            .connect()\n            .upsert_http_response(&final_response, &params.update_source, params.blob_manager)\n            .map_err(SendHttpRequestError::PersistResponse)?;\n    } else {\n        response = final_response;\n    }\n\n    persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_store.as_ref())?;\n\n    Ok(SendHttpRequestResult { rendered_request, response, response_body })\n}\n\nfn persist_request_body_bytes(\n    blob_manager: &BlobManager,\n    body_id: &str,\n    bytes: &[u8],\n) -> std::result::Result<(), String> {\n    if bytes.is_empty() {\n        return Ok(());\n    }\n\n    let blob_ctx = blob_manager.connect();\n    let mut offset = 0;\n    let mut chunk_index: i32 = 0;\n    while offset < bytes.len() {\n        let end = std::cmp::min(offset + REQUEST_BODY_CHUNK_SIZE, bytes.len());\n        let chunk = BodyChunk::new(body_id, chunk_index, bytes[offset..end].to_vec());\n        blob_ctx.insert_chunk(&chunk).map_err(|e| e.to_string())?;\n        chunk_index += 1;\n        offset = end;\n    }\n    Ok(())\n}\n\nasync fn persist_request_body_stream(\n    blob_manager: BlobManager,\n    body_id: String,\n    mut rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,\n) -> std::result::Result<usize, String> {\n    let mut chunk_index: i32 = 0;\n    let mut total_bytes = 0usize;\n    while let Some(data) = rx.recv().await {\n        total_bytes += data.len();\n        if data.is_empty() {\n            continue;\n        }\n        let chunk = BodyChunk::new(&body_id, chunk_index, data);\n        blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?;\n        chunk_index += 1;\n    }\n\n    Ok(total_bytes)\n}\n\nfn append_error_message(existing_error: Option<String>, message: String) -> String {\n    match existing_error {\n        Some(existing) => format!(\"{existing}; {message}\"),\n        None => message,\n    }\n}\n\nfn resolve_environment_chain(\n    query_manager: &QueryManager,\n    request: &HttpRequest,\n    environment_id: Option<&str>,\n) -> Result<Vec<Environment>> {\n    let db = query_manager.connect();\n    db.resolve_environments(&request.workspace_id, request.folder_id.as_deref(), environment_id)\n        .map_err(SendHttpRequestError::ResolveEnvironments)\n}\n\nfn resolve_inherited_request(\n    query_manager: &QueryManager,\n    request: &HttpRequest,\n) -> Result<(HttpRequest, String)> {\n    let db = query_manager.connect();\n    let (authentication_type, authentication, auth_context_id) = db\n        .resolve_auth_for_http_request(request)\n        .map_err(SendHttpRequestError::ResolveRequestInheritance)?;\n    let resolved_headers = db\n        .resolve_headers_for_http_request(request)\n        .map_err(SendHttpRequestError::ResolveRequestInheritance)?;\n\n    let mut request = request.clone();\n    request.authentication_type = authentication_type;\n    request.authentication = authentication;\n    request.headers = resolved_headers;\n\n    Ok((request, auth_context_id))\n}\n\nfn load_cookie_jar(\n    query_manager: &QueryManager,\n    cookie_jar_id: Option<&str>,\n) -> Result<Option<CookieJar>> {\n    let Some(cookie_jar_id) = cookie_jar_id else {\n        return Ok(None);\n    };\n\n    query_manager\n        .connect()\n        .get_cookie_jar(cookie_jar_id)\n        .map(Some)\n        .map_err(SendHttpRequestError::LoadCookieJar)\n}\n\nfn persist_cookie_jar(\n    query_manager: &QueryManager,\n    cookie_jar: Option<&mut CookieJar>,\n    cookie_store: Option<&CookieStore>,\n) -> Result<()> {\n    match (cookie_jar, cookie_store) {\n        (Some(cookie_jar), Some(cookie_store)) => {\n            cookie_jar.cookies = cookie_store.get_all_cookies();\n            query_manager\n                .connect()\n                .upsert_cookie_jar(cookie_jar, &UpdateSource::Background)\n                .map_err(SendHttpRequestError::PersistCookieJar)?;\n            Ok(())\n        }\n        _ => Ok(()),\n    }\n}\n\nfn proxy_setting_from_settings(proxy: Option<ProxySetting>) -> HttpConnectionProxySetting {\n    match proxy {\n        None => HttpConnectionProxySetting::System,\n        Some(ProxySetting::Disabled) => HttpConnectionProxySetting::Disabled,\n        Some(ProxySetting::Enabled { http, https, auth, bypass, disabled }) => {\n            if disabled {\n                HttpConnectionProxySetting::System\n            } else {\n                HttpConnectionProxySetting::Enabled {\n                    http,\n                    https,\n                    bypass,\n                    auth: auth.map(|ProxySettingAuth { user, password }| {\n                        HttpConnectionProxySettingAuth { user, password }\n                    }),\n                }\n            }\n        }\n    }\n}\n\npub async fn apply_plugin_authentication(\n    sendable_request: &mut SendableHttpRequest,\n    request: &HttpRequest,\n    auth_context_id: &str,\n    plugin_manager: &PluginManager,\n    plugin_context: &PluginContext,\n) -> std::result::Result<(), String> {\n    match &request.authentication_type {\n        None => {}\n        Some(authentication_type) if authentication_type == \"none\" => {}\n        Some(authentication_type) => {\n            let req = CallHttpAuthenticationRequest {\n                context_id: format!(\"{:x}\", md5::compute(auth_context_id)),\n                values: serde_json::from_value(\n                    serde_json::to_value(&request.authentication)\n                        .map_err(|e| format!(\"Failed to serialize auth values: {e}\"))?,\n                )\n                .map_err(|e| format!(\"Failed to parse auth values: {e}\"))?,\n                url: sendable_request.url.clone(),\n                method: sendable_request.method.clone(),\n                headers: sendable_request\n                    .headers\n                    .iter()\n                    .map(|(name, value)| HttpHeader {\n                        name: name.to_string(),\n                        value: value.to_string(),\n                    })\n                    .collect(),\n            };\n            let plugin_result = plugin_manager\n                .call_http_authentication(plugin_context, authentication_type, req)\n                .await\n                .map_err(|e| format!(\"Failed to apply authentication plugin: {e}\"))?;\n\n            for header in plugin_result.set_headers.unwrap_or_default() {\n                sendable_request.insert_header((header.name, header.value));\n            }\n\n            if let Some(params) = plugin_result.set_query_parameters {\n                let params = params.into_iter().map(|p| (p.name, p.value)).collect::<Vec<_>>();\n                sendable_request.url = append_query_params(&sendable_request.url, params);\n            }\n        }\n    }\n    Ok(())\n}\n\nfn persist_response_error(\n    query_manager: &QueryManager,\n    blob_manager: &BlobManager,\n    update_source: &UpdateSource,\n    response: &HttpResponse,\n    started_at: Instant,\n    error: String,\n    fallback_url: String,\n) -> Result<HttpResponse> {\n    let elapsed = duration_to_i32(started_at.elapsed());\n    query_manager\n        .connect()\n        .upsert_http_response(\n            &HttpResponse {\n                state: HttpResponseState::Closed,\n                elapsed,\n                elapsed_headers: if response.elapsed_headers == 0 {\n                    elapsed\n                } else {\n                    response.elapsed_headers\n                },\n                error: Some(error),\n                url: if response.url.is_empty() { fallback_url } else { response.url.clone() },\n                ..response.clone()\n            },\n            update_source,\n            blob_manager,\n        )\n        .map_err(SendHttpRequestError::PersistResponse)\n}\n\nfn sendable_body_length(body: Option<&SendableBody>) -> Option<i32> {\n    match body {\n        Some(SendableBody::Bytes(bytes)) => Some(usize_to_i32(bytes.len())),\n        Some(SendableBody::Stream { content_length: Some(length), .. }) => {\n            Some(u64_to_i32(*length))\n        }\n        _ => None,\n    }\n}\n\nfn duration_to_i32(duration: std::time::Duration) -> i32 {\n    u128_to_i32(duration.as_millis())\n}\n\nfn usize_to_i32(value: usize) -> i32 {\n    if value > i32::MAX as usize { i32::MAX } else { value as i32 }\n}\n\nfn u64_to_i32(value: u64) -> i32 {\n    if value > i32::MAX as u64 { i32::MAX } else { value as i32 }\n}\n\nfn u128_to_i32(value: u128) -> i32 {\n    if value > i32::MAX as u128 { i32::MAX } else { value as i32 }\n}\n"
  },
  {
    "path": "crates/yaak-api/Cargo.toml",
    "content": "[package]\nname = \"yaak-api\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nlog = { workspace = true }\nreqwest = { workspace = true, features = [\"gzip\"] }\nsysproxy = \"0.3\"\nthiserror = { workspace = true }\nyaak-common = { workspace = true }\n"
  },
  {
    "path": "crates/yaak-api/src/error.rs",
    "content": "use thiserror::Error;\n\n#[derive(Error, Debug)]\npub enum Error {\n    #[error(transparent)]\n    ReqwestError(#[from] reqwest::Error),\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates/yaak-api/src/lib.rs",
    "content": "mod error;\n\npub use error::{Error, Result};\n\nuse log::{debug, warn};\nuse reqwest::Client;\nuse reqwest::header::{HeaderMap, HeaderValue};\nuse std::time::Duration;\nuse yaak_common::platform::{get_ua_arch, get_ua_platform};\n\n#[derive(Debug, Clone, Copy, Eq, PartialEq)]\npub enum ApiClientKind {\n    App,\n    Cli,\n}\n\n/// Build a reqwest Client configured for Yaak's own API calls.\n///\n/// Includes a custom User-Agent, JSON accept header, 20s timeout, gzip,\n/// and automatic OS-level proxy detection via sysproxy.\npub fn yaak_api_client(kind: ApiClientKind, version: &str) -> Result<Client> {\n    let platform = get_ua_platform();\n    let arch = get_ua_arch();\n    let product = match kind {\n        ApiClientKind::App => \"Yaak\",\n        ApiClientKind::Cli => \"YaakCli\",\n    };\n    let ua = format!(\"{product}/{version} ({platform}; {arch})\");\n\n    let mut default_headers = HeaderMap::new();\n    default_headers.insert(\"Accept\", HeaderValue::from_str(\"application/json\").unwrap());\n\n    let mut builder = reqwest::ClientBuilder::new()\n        .timeout(Duration::from_secs(20))\n        .default_headers(default_headers)\n        .gzip(true)\n        .user_agent(ua);\n\n    if let Some(sys) = get_enabled_system_proxy() {\n        let proxy_url = format!(\"http://{}:{}\", sys.host, sys.port);\n        match reqwest::Proxy::all(&proxy_url) {\n            Ok(p) => {\n                let p = if !sys.bypass.is_empty() {\n                    p.no_proxy(reqwest::NoProxy::from_string(&sys.bypass))\n                } else {\n                    p\n                };\n                builder = builder.proxy(p);\n            }\n            Err(e) => {\n                warn!(\"Failed to configure system proxy: {e}\");\n            }\n        }\n    }\n\n    Ok(builder.build()?)\n}\n\n/// Returns the system proxy URL if one is enabled, e.g. `http://host:port`.\npub fn get_system_proxy_url() -> Option<String> {\n    let sys = get_enabled_system_proxy()?;\n    Some(format!(\"http://{}:{}\", sys.host, sys.port))\n}\n\nfn get_enabled_system_proxy() -> Option<sysproxy::Sysproxy> {\n    match sysproxy::Sysproxy::get_system_proxy() {\n        Ok(sys) if sys.enable => {\n            debug!(\"Detected system proxy: http://{}:{}\", sys.host, sys.port);\n            Some(sys)\n        }\n        Ok(_) => {\n            debug!(\"System proxy detected but not enabled\");\n            None\n        }\n        Err(e) => {\n            debug!(\"Could not detect system proxy: {e}\");\n            None\n        }\n    }\n}\n"
  },
  {
    "path": "crates/yaak-common/Cargo.toml",
    "content": "[package]\nname = \"yaak-common\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nserde_json = { workspace = true }\ntokio = { workspace = true, features = [\"process\"] }\n"
  },
  {
    "path": "crates/yaak-common/src/command.rs",
    "content": "use std::ffi::{OsStr, OsString};\nuse std::io::{self, ErrorKind};\nuse std::process::Stdio;\n\n#[cfg(target_os = \"windows\")]\nconst CREATE_NO_WINDOW: u32 = 0x0800_0000;\n\n/// Creates a new `tokio::process::Command` that won't spawn a console window on Windows.\npub fn new_xplatform_command<S: AsRef<OsStr>>(program: S) -> tokio::process::Command {\n    #[allow(unused_mut)]\n    let mut cmd = tokio::process::Command::new(program);\n    #[cfg(target_os = \"windows\")]\n    {\n        use std::os::windows::process::CommandExt;\n        cmd.creation_flags(CREATE_NO_WINDOW);\n    }\n    cmd\n}\n\n/// Creates a command only if the binary exists and can be invoked with the given probe argument.\npub async fn new_checked_command<S: AsRef<OsStr>>(\n    program: S,\n    probe_arg: &str,\n) -> io::Result<tokio::process::Command> {\n    let program: OsString = program.as_ref().to_os_string();\n\n    let mut probe = new_xplatform_command(&program);\n    probe.arg(probe_arg).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());\n\n    let status = probe.status().await?;\n    if !status.success() {\n        return Err(io::Error::new(\n            ErrorKind::NotFound,\n            format!(\n                \"'{}' is not available on PATH or failed to execute\",\n                program.to_string_lossy()\n            ),\n        ));\n    }\n\n    Ok(new_xplatform_command(&program))\n}\n"
  },
  {
    "path": "crates/yaak-common/src/lib.rs",
    "content": "pub mod command;\npub mod platform;\npub mod serde;\n"
  },
  {
    "path": "crates/yaak-common/src/platform.rs",
    "content": "use crate::platform::OperatingSystem::{Linux, MacOS, Unknown, Windows};\n\npub enum OperatingSystem {\n    Windows,\n    MacOS,\n    Linux,\n    Unknown,\n}\n\npub fn get_os() -> OperatingSystem {\n    if cfg!(target_os = \"windows\") {\n        Windows\n    } else if cfg!(target_os = \"macos\") {\n        MacOS\n    } else if cfg!(target_os = \"linux\") {\n        Linux\n    } else {\n        Unknown\n    }\n}\n\npub fn get_os_str() -> &'static str {\n    match get_os() {\n        Windows => \"windows\",\n        MacOS => \"macos\",\n        Linux => \"linux\",\n        Unknown => \"unknown\",\n    }\n}\n\npub fn get_ua_platform() -> &'static str {\n    if cfg!(target_os = \"windows\") {\n        \"Win\"\n    } else if cfg!(target_os = \"macos\") {\n        \"Mac\"\n    } else if cfg!(target_os = \"linux\") {\n        \"Linux\"\n    } else {\n        \"Unknown\"\n    }\n}\n\npub fn get_ua_arch() -> &'static str {\n    if cfg!(target_arch = \"x86_64\") {\n        \"x86_64\"\n    } else if cfg!(target_arch = \"x86\") {\n        \"i386\"\n    } else if cfg!(target_arch = \"arm\") {\n        \"ARM\"\n    } else if cfg!(target_arch = \"aarch64\") {\n        \"ARM64\"\n    } else {\n        \"Unknown\"\n    }\n}\n"
  },
  {
    "path": "crates/yaak-common/src/serde.rs",
    "content": "use serde_json::Value;\nuse std::collections::BTreeMap;\n\npub fn get_bool(v: &Value, key: &str, fallback: bool) -> bool {\n    match v.get(key) {\n        None => fallback,\n        Some(v) => v.as_bool().unwrap_or(fallback),\n    }\n}\n\npub fn get_str<'a>(v: &'a Value, key: &str) -> &'a str {\n    match v.get(key) {\n        None => \"\",\n        Some(v) => v.as_str().unwrap_or_default(),\n    }\n}\n\npub fn get_str_map<'a>(v: &'a BTreeMap<String, Value>, key: &str) -> &'a str {\n    match v.get(key) {\n        None => \"\",\n        Some(v) => v.as_str().unwrap_or_default(),\n    }\n}\n\npub fn get_bool_map(v: &BTreeMap<String, Value>, key: &str, fallback: bool) -> bool {\n    match v.get(key) {\n        None => fallback,\n        Some(v) => v.as_bool().unwrap_or(fallback),\n    }\n}\n"
  },
  {
    "path": "crates/yaak-core/Cargo.toml",
    "content": "[package]\nname = \"yaak-core\"\nversion = \"0.0.0\"\nedition = \"2024\"\nauthors = [\"Gregory Schier\"]\npublish = false\n\n[dependencies]\nthiserror = { workspace = true }\n"
  },
  {
    "path": "crates/yaak-core/src/context.rs",
    "content": "use std::path::PathBuf;\n\n/// Context for a workspace operation.\n///\n/// In Tauri, this is extracted from the WebviewWindow URL.\n/// In CLI, this is constructed from command arguments or config.\n#[derive(Debug, Clone, Default)]\npub struct WorkspaceContext {\n    pub workspace_id: Option<String>,\n    pub environment_id: Option<String>,\n    pub cookie_jar_id: Option<String>,\n    pub request_id: Option<String>,\n}\n\nimpl WorkspaceContext {\n    pub fn new() -> Self {\n        Self::default()\n    }\n\n    pub fn with_workspace(mut self, workspace_id: impl Into<String>) -> Self {\n        self.workspace_id = Some(workspace_id.into());\n        self\n    }\n\n    pub fn with_environment(mut self, environment_id: impl Into<String>) -> Self {\n        self.environment_id = Some(environment_id.into());\n        self\n    }\n\n    pub fn with_cookie_jar(mut self, cookie_jar_id: impl Into<String>) -> Self {\n        self.cookie_jar_id = Some(cookie_jar_id.into());\n        self\n    }\n\n    pub fn with_request(mut self, request_id: impl Into<String>) -> Self {\n        self.request_id = Some(request_id.into());\n        self\n    }\n}\n\n/// Application context trait for accessing app-level resources.\n///\n/// This abstracts over Tauri's `AppHandle` for path resolution and app identity.\n/// Implemented by Tauri's AppHandle and by CLI's own context struct.\npub trait AppContext: Send + Sync + Clone {\n    /// Returns the path to the application data directory.\n    /// This is where the database and other persistent data are stored.\n    fn app_data_dir(&self) -> PathBuf;\n\n    /// Returns the application identifier (e.g., \"app.yaak.desktop\").\n    /// Used for keyring access and other platform-specific features.\n    fn app_identifier(&self) -> &str;\n\n    /// Returns true if running in development mode.\n    fn is_dev(&self) -> bool;\n}\n"
  },
  {
    "path": "crates/yaak-core/src/error.rs",
    "content": "use thiserror::Error;\n\npub type Result<T> = std::result::Result<T, Error>;\n\n#[derive(Error, Debug)]\npub enum Error {\n    #[error(\"Missing required context: {0}\")]\n    MissingContext(String),\n\n    #[error(\"Configuration error: {0}\")]\n    Config(String),\n\n    #[error(\"IO error: {0}\")]\n    Io(#[from] std::io::Error),\n}\n"
  },
  {
    "path": "crates/yaak-core/src/lib.rs",
    "content": "//! Core abstractions for Yaak that work without Tauri.\n//!\n//! This crate provides foundational types and traits that allow Yaak's\n//! business logic to run in both Tauri (desktop app) and CLI contexts.\n\nmod context;\nmod error;\n\npub use context::{AppContext, WorkspaceContext};\npub use error::{Error, Result};\n"
  },
  {
    "path": "crates/yaak-crypto/Cargo.toml",
    "content": "[package]\nname = \"yaak-crypto\"\nversion = \"0.1.0\"\nedition = \"2021\"\npublish = false\n\n[dependencies]\nbase32 = \"0.5.1\" # For encoding human-readable key\nbase64 = \"0.22.1\" # For encoding in the database\nchacha20poly1305 = \"0.10.1\"\nkeyring = { workspace = true, features = [\"apple-native\", \"windows-native\", \"sync-secret-service\"] }\nlog = { workspace = true }\nserde = { workspace = true, features = [\"derive\"] }\nthiserror = { workspace = true }\nyaak-models = { workspace = true }\n"
  },
  {
    "path": "crates/yaak-crypto/index.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\n\nexport function enableEncryption(workspaceId: string) {\n  return invoke<void>(\"cmd_enable_encryption\", { workspaceId });\n}\n\nexport function revealWorkspaceKey(workspaceId: string) {\n  return invoke<string>(\"cmd_reveal_workspace_key\", { workspaceId });\n}\n\nexport function setWorkspaceKey(args: { workspaceId: string; key: string }) {\n  return invoke<void>(\"cmd_set_workspace_key\", args);\n}\n\nexport function disableEncryption(workspaceId: string) {\n  return invoke<void>(\"cmd_disable_encryption\", { workspaceId });\n}\n"
  },
  {
    "path": "crates/yaak-crypto/package.json",
    "content": "{\n  \"name\": \"@yaakapp-internal/crypto\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"index.ts\"\n}\n"
  },
  {
    "path": "crates/yaak-crypto/src/encryption.rs",
    "content": "use crate::error::Error::{DecryptionError, EncryptionError, InvalidEncryptedData};\nuse crate::error::Result;\nuse chacha20poly1305::aead::generic_array::typenum::Unsigned;\nuse chacha20poly1305::aead::{Aead, AeadCore, Key, KeyInit, OsRng};\nuse chacha20poly1305::XChaCha20Poly1305;\n\nconst ENCRYPTION_TAG: &str = \"yA4k3nC\";\nconst ENCRYPTION_VERSION: u8 = 1;\n\npub(crate) fn encrypt_data(data: &[u8], key: &Key<XChaCha20Poly1305>) -> Result<Vec<u8>> {\n    let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);\n    let cipher = XChaCha20Poly1305::new(&key);\n    let ciphered_data = cipher.encrypt(&nonce, data).map_err(|_| EncryptionError)?;\n\n    let mut data: Vec<u8> = Vec::new();\n    data.extend_from_slice(ENCRYPTION_TAG.as_bytes()); // Tag\n    data.push(ENCRYPTION_VERSION); // Version\n    data.extend_from_slice(&nonce.as_slice()); // Nonce\n    data.extend_from_slice(&ciphered_data); // Ciphertext\n\n    Ok(data)\n}\n\npub(crate) fn decrypt_data(cipher_data: &[u8], key: &Key<XChaCha20Poly1305>) -> Result<Vec<u8>> {\n    // Yaak Tag + ID + Version + Nonce + ... ciphertext ...\n    let (tag, rest) =\n        cipher_data.split_at_checked(ENCRYPTION_TAG.len()).ok_or(InvalidEncryptedData)?;\n    if tag != ENCRYPTION_TAG.as_bytes() {\n        return Err(InvalidEncryptedData);\n    }\n\n    let (version, rest) = rest.split_at_checked(1).ok_or(InvalidEncryptedData)?;\n    if version[0] != ENCRYPTION_VERSION {\n        return Err(InvalidEncryptedData);\n    }\n\n    let nonce_bytes = <XChaCha20Poly1305 as AeadCore>::NonceSize::to_usize();\n    let (nonce, ciphered_data) = rest.split_at_checked(nonce_bytes).ok_or(InvalidEncryptedData)?;\n\n    let cipher = XChaCha20Poly1305::new(&key);\n    cipher.decrypt(nonce.into(), ciphered_data).map_err(|_e| DecryptionError)\n}\n\n#[cfg(test)]\nmod test {\n    use crate::encryption::{decrypt_data, encrypt_data};\n    use crate::error::Error::InvalidEncryptedData;\n    use crate::error::Result;\n    use chacha20poly1305::aead::OsRng;\n    use chacha20poly1305::{KeyInit, XChaCha20Poly1305};\n\n    #[test]\n    fn test_encrypt_decrypt() -> Result<()> {\n        let key = XChaCha20Poly1305::generate_key(OsRng);\n        let encrypted = encrypt_data(\"hello world\".as_bytes(), &key)?;\n        let decrypted = decrypt_data(encrypted.as_slice(), &key)?;\n        assert_eq!(String::from_utf8(decrypted).unwrap(), \"hello world\");\n        Ok(())\n    }\n\n    #[test]\n    fn test_decrypt_empty() -> Result<()> {\n        let key = XChaCha20Poly1305::generate_key(OsRng);\n        let encrypted = encrypt_data(&[], &key)?;\n        assert_eq!(encrypted.len(), 48);\n        let decrypted = decrypt_data(encrypted.as_slice(), &key)?;\n        assert_eq!(String::from_utf8(decrypted).unwrap(), \"\");\n        Ok(())\n    }\n\n    #[test]\n    fn test_decrypt_bad_version() -> Result<()> {\n        let key = XChaCha20Poly1305::generate_key(OsRng);\n        let mut encrypted = encrypt_data(\"hello world\".as_bytes(), &key)?;\n        encrypted[7] = 0;\n        let decrypted = decrypt_data(encrypted.as_slice(), &key);\n        assert!(matches!(decrypted, Err(InvalidEncryptedData)));\n        Ok(())\n    }\n\n    #[test]\n    fn test_decrypt_bad_tag() -> Result<()> {\n        let key = XChaCha20Poly1305::generate_key(OsRng);\n        let mut encrypted = encrypt_data(\"hello world\".as_bytes(), &key)?;\n        encrypted[0] = 2;\n        let decrypted = decrypt_data(encrypted.as_slice(), &key);\n        assert!(matches!(decrypted, Err(InvalidEncryptedData)));\n        Ok(())\n    }\n\n    #[test]\n    fn test_decrypt_unencrypted_data() -> Result<()> {\n        let key = XChaCha20Poly1305::generate_key(OsRng);\n        let decrypted = decrypt_data(\"123\".as_bytes(), &key);\n        assert!(matches!(decrypted, Err(InvalidEncryptedData)));\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/yaak-crypto/src/error.rs",
    "content": "use serde::{Serialize, Serializer};\nuse std::io;\nuse thiserror::Error;\n\n#[derive(Error, Debug)]\npub enum Error {\n    #[error(transparent)]\n    DbError(#[from] yaak_models::error::Error),\n\n    #[error(\"Keyring error: {0}\")]\n    KeyringError(#[from] keyring::Error),\n\n    #[error(\"Missing workspace encryption key\")]\n    MissingWorkspaceKey,\n\n    #[error(\"Incorrect workspace key\")]\n    IncorrectWorkspaceKey,\n\n    #[error(\"Failed to decrypt workspace key: {0}\")]\n    WorkspaceKeyDecryptionError(String),\n\n    #[error(\"Crypto IO error: {0}\")]\n    IoError(#[from] io::Error),\n\n    #[error(\"Failed to encrypt data\")]\n    EncryptionError,\n\n    #[error(\"Failed to decrypt data\")]\n    DecryptionError,\n\n    #[error(\"Invalid encrypted data\")]\n    InvalidEncryptedData,\n\n    #[error(\"Invalid key provided\")]\n    InvalidHumanKey,\n\n    #[error(\"Encryption error: {0}\")]\n    GenericError(String),\n}\n\nimpl Serialize for Error {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        serializer.serialize_str(self.to_string().as_ref())\n    }\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates/yaak-crypto/src/lib.rs",
    "content": "extern crate core;\n\npub mod encryption;\npub mod error;\npub mod manager;\nmod master_key;\nmod workspace_key;\n"
  },
  {
    "path": "crates/yaak-crypto/src/manager.rs",
    "content": "use crate::error::Error::{\n    GenericError, IncorrectWorkspaceKey, MissingWorkspaceKey, WorkspaceKeyDecryptionError,\n};\nuse crate::error::{Error, Result};\nuse crate::master_key::MasterKey;\nuse crate::workspace_key::WorkspaceKey;\nuse base64::prelude::BASE64_STANDARD;\nuse base64::Engine;\nuse log::{info, warn};\nuse std::collections::HashMap;\nuse std::sync::{Arc, Mutex};\nuse yaak_models::models::{EncryptedKey, Workspace, WorkspaceMeta};\nuse yaak_models::query_manager::QueryManager;\nuse yaak_models::util::{generate_id_of_length, UpdateSource};\n\nconst KEY_USER: &str = \"encryption-key\";\n\n#[derive(Debug, Clone)]\npub struct EncryptionManager {\n    cached_master_key: Arc<Mutex<Option<MasterKey>>>,\n    cached_workspace_keys: Arc<Mutex<HashMap<String, WorkspaceKey>>>,\n    query_manager: QueryManager,\n    app_id: String,\n}\n\nimpl EncryptionManager {\n    pub fn new(query_manager: QueryManager, app_id: impl Into<String>) -> Self {\n        Self {\n            cached_master_key: Default::default(),\n            cached_workspace_keys: Default::default(),\n            query_manager,\n            app_id: app_id.into(),\n        }\n    }\n\n    pub fn encrypt(&self, workspace_id: &str, data: &[u8]) -> Result<Vec<u8>> {\n        let workspace_secret = self.get_workspace_key(workspace_id)?;\n        workspace_secret.encrypt(data)\n    }\n\n    pub fn decrypt(&self, workspace_id: &str, data: &[u8]) -> Result<Vec<u8>> {\n        let workspace_secret = self.get_workspace_key(workspace_id)?;\n        workspace_secret.decrypt(data)\n    }\n\n    pub fn reveal_workspace_key(&self, workspace_id: &str) -> Result<String> {\n        let key = self.get_workspace_key(workspace_id)?;\n        key.to_human()\n    }\n\n    pub fn set_human_key(&self, workspace_id: &str, human_key: &str) -> Result<WorkspaceMeta> {\n        let wkey = WorkspaceKey::from_human(human_key)?;\n\n        let workspace = self.query_manager.connect().get_workspace(workspace_id)?;\n        let encryption_key_challenge = match workspace.encryption_key_challenge {\n            None => return self.set_workspace_key(workspace_id, &wkey),\n            Some(c) => c,\n        };\n\n        let encryption_key_challenge = match BASE64_STANDARD.decode(encryption_key_challenge) {\n            Ok(c) => c,\n            Err(_) => return Err(GenericError(\"Failed to decode workspace challenge\".to_string())),\n        };\n\n        if let Err(_) = wkey.decrypt(encryption_key_challenge.as_slice()) {\n            return Err(IncorrectWorkspaceKey);\n        };\n\n        self.set_workspace_key(workspace_id, &wkey)\n    }\n\n    pub(crate) fn set_workspace_key(\n        &self,\n        workspace_id: &str,\n        wkey: &WorkspaceKey,\n    ) -> Result<WorkspaceMeta> {\n        info!(\"Created workspace key for {workspace_id}\");\n\n        let encrypted_key = BASE64_STANDARD.encode(self.get_master_key()?.encrypt(wkey.raw_key())?);\n        let encrypted_key = EncryptedKey { encrypted_key };\n        let encryption_key_challenge = wkey.encrypt(generate_id_of_length(50).as_bytes())?;\n        let encryption_key_challenge = Some(BASE64_STANDARD.encode(encryption_key_challenge));\n\n        let workspace_meta = self.query_manager.with_tx::<WorkspaceMeta, Error>(|tx| {\n            let workspace = tx.get_workspace(workspace_id)?;\n            let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?;\n            tx.upsert_workspace(\n                &Workspace { encryption_key_challenge, ..workspace },\n                &UpdateSource::Background,\n            )?;\n\n            Ok(tx.upsert_workspace_meta(\n                &WorkspaceMeta { encryption_key: Some(encrypted_key.clone()), ..workspace_meta },\n                &UpdateSource::Background,\n            )?)\n        })?;\n\n        let mut cache = self.cached_workspace_keys.lock().unwrap();\n        cache.insert(workspace_id.to_string(), wkey.clone());\n\n        Ok(workspace_meta)\n    }\n\n    pub fn ensure_workspace_key(&self, workspace_id: &str) -> Result<WorkspaceMeta> {\n        let workspace_meta =\n            self.query_manager.connect().get_or_create_workspace_meta(workspace_id)?;\n\n        // Already exists\n        if let Some(_) = workspace_meta.encryption_key {\n            warn!(\"Tried to create workspace key when one already exists for {workspace_id}\");\n            return Ok(workspace_meta);\n        }\n\n        let wkey = WorkspaceKey::create()?;\n        self.set_workspace_key(workspace_id, &wkey)\n    }\n\n    pub fn disable_encryption(&self, workspace_id: &str) -> Result<()> {\n        info!(\"Disabling encryption for {workspace_id}\");\n\n        self.query_manager.with_tx::<(), Error>(|tx| {\n            let workspace = tx.get_workspace(workspace_id)?;\n            let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?;\n\n            // Clear encryption challenge on workspace\n            tx.upsert_workspace(\n                &Workspace { encryption_key_challenge: None, ..workspace },\n                &UpdateSource::Background,\n            )?;\n\n            // Clear encryption key on workspace meta\n            tx.upsert_workspace_meta(\n                &WorkspaceMeta { encryption_key: None, ..workspace_meta },\n                &UpdateSource::Background,\n            )?;\n\n            Ok(())\n        })?;\n\n        // Remove from cache\n        let mut cache = self.cached_workspace_keys.lock().unwrap();\n        cache.remove(workspace_id);\n\n        Ok(())\n    }\n\n    fn get_workspace_key(&self, workspace_id: &str) -> Result<WorkspaceKey> {\n        {\n            let cache = self.cached_workspace_keys.lock().unwrap();\n            if let Some(k) = cache.get(workspace_id) {\n                return Ok(k.clone());\n            }\n        };\n\n        let db = self.query_manager.connect();\n        let workspace_meta = db.get_or_create_workspace_meta(workspace_id)?;\n\n        let key = match workspace_meta.encryption_key {\n            None => return Err(MissingWorkspaceKey),\n            Some(k) => k,\n        };\n\n        let mkey = self.get_master_key()?;\n        let decoded_key = BASE64_STANDARD\n            .decode(key.encrypted_key)\n            .map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?;\n        let raw_key = mkey\n            .decrypt(decoded_key.as_slice())\n            .map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?;\n        let wkey = WorkspaceKey::from_raw_key(raw_key.as_slice());\n\n        Ok(wkey)\n    }\n\n    fn get_master_key(&self) -> Result<MasterKey> {\n        // NOTE: This locks the key for the entire function which seems wrong, but this prevents\n        // concurrent access from prompting the user for a keychain password multiple times.\n        let mut master_secret = self.cached_master_key.lock().unwrap();\n        if let Some(k) = master_secret.as_ref() {\n            return Ok(k.to_owned());\n        }\n\n        let mkey = MasterKey::get_or_create(&self.app_id, KEY_USER)?;\n        *master_secret = Some(mkey.clone());\n        Ok(mkey)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-crypto/src/master_key.rs",
    "content": "use crate::encryption::{decrypt_data, encrypt_data};\nuse crate::error::Error::GenericError;\nuse crate::error::Result;\nuse base32::Alphabet;\nuse chacha20poly1305::aead::{Key, KeyInit, OsRng};\nuse chacha20poly1305::XChaCha20Poly1305;\nuse keyring::{Entry, Error};\nuse log::info;\n\nconst HUMAN_PREFIX: &str = \"YKM_\";\n\n#[derive(Debug, Clone)]\npub(crate) struct MasterKey {\n    key: Key<XChaCha20Poly1305>,\n}\n\nimpl MasterKey {\n    pub(crate) fn get_or_create(app_id: &str, user: &str) -> Result<Self> {\n        let id = format!(\"{app_id}.EncryptionKey\");\n        let entry = Entry::new(&id, user)?;\n\n        let key = match entry.get_password() {\n            Ok(encoded) => {\n                let without_prefix = encoded.strip_prefix(HUMAN_PREFIX).unwrap_or(&encoded);\n                let key_bytes = base32::decode(Alphabet::Crockford {}, &without_prefix)\n                    .ok_or(GenericError(\"Failed to decode master key\".to_string()))?;\n                Key::<XChaCha20Poly1305>::clone_from_slice(key_bytes.as_slice())\n            }\n            Err(Error::NoEntry) => {\n                info!(\"Creating new master key\");\n                let key = XChaCha20Poly1305::generate_key(OsRng);\n                let encoded = base32::encode(Alphabet::Crockford {}, key.as_slice());\n                let with_prefix = format!(\"{HUMAN_PREFIX}{encoded}\");\n                entry.set_password(&with_prefix)?;\n                key\n            }\n            Err(e) => return Err(GenericError(e.to_string())),\n        };\n\n        Ok(Self { key })\n    }\n\n    pub(crate) fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>> {\n        encrypt_data(data, &self.key)\n    }\n\n    pub(crate) fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>> {\n        decrypt_data(data, &self.key)\n    }\n\n    #[cfg(test)]\n    pub(crate) fn test_key() -> Self {\n        let key: Key<XChaCha20Poly1305> = Key::<XChaCha20Poly1305>::clone_from_slice(\n            \"00000000000000000000000000000000\".as_bytes(),\n        );\n        Self { key }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::error::Result;\n    use crate::master_key::MasterKey;\n\n    #[test]\n    fn test_master_key() -> Result<()> {\n        // Test out the master key\n        let mkey = MasterKey::test_key();\n        let encrypted = mkey.encrypt(\"hello\".as_bytes())?;\n        let decrypted = mkey.decrypt(encrypted.as_slice()).unwrap();\n        assert_eq!(decrypted, \"hello\".as_bytes().to_vec());\n\n        let mkey = MasterKey::test_key();\n        let decrypted = mkey.decrypt(encrypted.as_slice()).unwrap();\n        assert_eq!(decrypted, \"hello\".as_bytes().to_vec());\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/yaak-crypto/src/workspace_key.rs",
    "content": "use crate::encryption::{decrypt_data, encrypt_data};\nuse crate::error::Error::InvalidHumanKey;\nuse crate::error::Result;\nuse base32::Alphabet;\nuse chacha20poly1305::aead::{Key, KeyInit, OsRng};\nuse chacha20poly1305::{KeySizeUser, XChaCha20Poly1305};\n\n#[derive(Debug, Clone)]\npub struct WorkspaceKey {\n    key: Key<XChaCha20Poly1305>,\n}\n\nconst HUMAN_PREFIX: &str = \"YK\";\n\nimpl WorkspaceKey {\n    pub(crate) fn to_human(&self) -> Result<String> {\n        let encoded = base32::encode(Alphabet::Crockford {}, self.key.as_slice());\n        let with_prefix = format!(\"{HUMAN_PREFIX}{encoded}\");\n        let with_separators = with_prefix\n            .chars()\n            .collect::<Vec<_>>()\n            .chunks(6)\n            .map(|chunk| chunk.iter().collect::<String>())\n            .collect::<Vec<_>>()\n            .join(\"-\");\n        Ok(with_separators)\n    }\n\n    #[allow(dead_code)]\n    pub(crate) fn from_human(human_key: &str) -> Result<Self> {\n        let without_prefix = human_key.strip_prefix(HUMAN_PREFIX).unwrap_or(human_key);\n        let without_separators = without_prefix.replace(\"-\", \"\");\n        let key =\n            base32::decode(Alphabet::Crockford {}, &without_separators).ok_or(InvalidHumanKey)?;\n        if key.len() != XChaCha20Poly1305::key_size() {\n            return Err(InvalidHumanKey);\n        }\n        Ok(Self::from_raw_key(key.as_slice()))\n    }\n\n    pub(crate) fn from_raw_key(key: &[u8]) -> Self {\n        Self { key: Key::<XChaCha20Poly1305>::clone_from_slice(key) }\n    }\n\n    pub(crate) fn raw_key(&self) -> &[u8] {\n        self.key.as_slice()\n    }\n\n    pub(crate) fn create() -> Result<Self> {\n        let key = XChaCha20Poly1305::generate_key(OsRng);\n        Ok(Self::from_raw_key(key.as_slice()))\n    }\n\n    pub(crate) fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>> {\n        encrypt_data(data, &self.key)\n    }\n\n    pub(crate) fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>> {\n        decrypt_data(data, &self.key)\n    }\n\n    #[cfg(test)]\n    pub(crate) fn test_key() -> Self {\n        Self::from_raw_key(\"f1a2d4b3c8e799af1456be3478a4c3f2\".as_bytes())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::error::Error::InvalidHumanKey;\n    use crate::error::Result;\n    use crate::workspace_key::WorkspaceKey;\n\n    #[test]\n    fn test_persisted_key() -> Result<()> {\n        let key = WorkspaceKey::test_key();\n        let encrypted = key.encrypt(\"hello\".as_bytes())?;\n        assert_eq!(key.decrypt(encrypted.as_slice())?, \"hello\".as_bytes());\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_human_format() -> Result<()> {\n        let key = WorkspaceKey::test_key();\n\n        let encrypted = key.encrypt(\"hello\".as_bytes())?;\n        assert_eq!(key.decrypt(encrypted.as_slice())?, \"hello\".as_bytes());\n\n        let human = key.to_human()?;\n        assert_eq!(human, \"YKCRRP-2CK46H-H36RSR-CMVKJE-B1CRRK-8D9PC9-JK6D1Q-71GK8R-SKCRS0\");\n        assert_eq!(\n            WorkspaceKey::from_human(&human)?.decrypt(encrypted.as_slice())?,\n            \"hello\".as_bytes()\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn test_from_human_invalid() -> Result<()> {\n        assert!(matches!(\n            WorkspaceKey::from_human(\n                \"YKCRRP-2CK46H-H36RSR-CMVKJE-B1CRRK-8D9PC9-JK6D1Q-71GK8R-SKCRS0-H3X38D\",\n            ),\n            Err(InvalidHumanKey)\n        ));\n\n        assert!(matches!(WorkspaceKey::from_human(\"bad-key\",), Err(InvalidHumanKey)));\n        assert!(matches!(WorkspaceKey::from_human(\"\",), Err(InvalidHumanKey)));\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/yaak-git/Cargo.toml",
    "content": "[package]\nname = \"yaak-git\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nchrono = { workspace = true, features = [\"serde\"] }\ngit2 = { version = \"0.20.4\", features = [\"vendored-libgit2\", \"vendored-openssl\"] }\nlog = { workspace = true }\nserde = { workspace = true, features = [\"derive\"] }\nserde_json = { workspace = true }\nserde_yaml = \"0.9.34\"\nthiserror = { workspace = true }\ntokio = { workspace = true, features = [\"io-util\"] }\nts-rs = { workspace = true, features = [\"chrono-impl\", \"serde-json-impl\"] }\nurl = \"2\"\nyaak-common = { workspace = true }\nyaak-models = { workspace = true }\nyaak-sync = { workspace = true }\n"
  },
  {
    "path": "crates/yaak-git/bindings/gen_git.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\nimport type { SyncModel } from \"./gen_models\";\n\nexport type BranchDeleteResult = { \"type\": \"success\", message: string, } | { \"type\": \"not_fully_merged\" };\n\nexport type CloneResult = { \"type\": \"success\" } | { \"type\": \"cancelled\" } | { \"type\": \"needs_credentials\", url: string, error: string | null, };\n\nexport type GitAuthor = { name: string | null, email: string | null, };\n\nexport type GitCommit = { author: GitAuthor, when: string, message: string | null, };\n\nexport type GitRemote = { name: string, url: string | null, };\n\nexport type GitStatus = \"untracked\" | \"conflict\" | \"current\" | \"modified\" | \"removed\" | \"renamed\" | \"type_change\";\n\nexport type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: SyncModel | null, next: SyncModel | null, };\n\nexport type GitStatusSummary = { path: string, headRef: string | null, headRefShorthand: string | null, entries: Array<GitStatusEntry>, origins: Array<string>, localBranches: Array<string>, remoteBranches: Array<string>, ahead: number, behind: number, };\n\nexport type PullResult = { \"type\": \"success\", message: string, } | { \"type\": \"up_to_date\" } | { \"type\": \"needs_credentials\", url: string, error: string | null, } | { \"type\": \"diverged\", remote: string, branch: string, } | { \"type\": \"uncommitted_changes\" };\n\nexport type PushResult = { \"type\": \"success\", message: string, } | { \"type\": \"up_to_date\" } | { \"type\": \"needs_credentials\", url: string, error: string | null, };\n"
  },
  {
    "path": "crates/yaak-git/bindings/gen_models.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };\n\nexport type Environment = { model: \"environment\", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };\n\nexport type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };\n\nexport type Folder = { model: \"folder\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };\n\nexport type GrpcRequest = { model: \"grpc_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };\n\nexport type HttpRequest = { model: \"http_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };\n\nexport type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };\n\nexport type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, };\n\nexport type SyncModel = { \"type\": \"workspace\" } & Workspace | { \"type\": \"environment\" } & Environment | { \"type\": \"folder\" } & Folder | { \"type\": \"http_request\" } & HttpRequest | { \"type\": \"grpc_request\" } & GrpcRequest | { \"type\": \"websocket_request\" } & WebsocketRequest;\n\nexport type WebsocketRequest = { model: \"websocket_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };\n\nexport type Workspace = { model: \"workspace\", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };\n"
  },
  {
    "path": "crates/yaak-git/index.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { createFastMutation } from \"@yaakapp/app/hooks/useFastMutation\";\nimport { queryClient } from \"@yaakapp/app/lib/queryClient\";\nimport { useMemo } from \"react\";\nimport {\n  BranchDeleteResult,\n  CloneResult,\n  GitCommit,\n  GitRemote,\n  GitStatusSummary,\n  PullResult,\n  PushResult,\n} from \"./bindings/gen_git\";\nimport { showToast } from \"@yaakapp/app/lib/toast\";\n\nexport * from \"./bindings/gen_git\";\nexport * from \"./bindings/gen_models\";\n\nexport interface GitCredentials {\n  username: string;\n  password: string;\n}\n\nexport type DivergedStrategy = \"force_reset\" | \"merge\" | \"cancel\";\n\nexport type UncommittedChangesStrategy = \"reset\" | \"cancel\";\n\nexport interface GitCallbacks {\n  addRemote: () => Promise<GitRemote | null>;\n  promptCredentials: (\n    result: Extract<PushResult, { type: \"needs_credentials\" }>,\n  ) => Promise<GitCredentials | null>;\n  promptDiverged: (result: Extract<PullResult, { type: \"diverged\" }>) => Promise<DivergedStrategy>;\n  promptUncommittedChanges: () => Promise<UncommittedChangesStrategy>;\n  forceSync: () => Promise<void>;\n}\n\nconst onSuccess = () => queryClient.invalidateQueries({ queryKey: [\"git\"] });\n\nexport function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {\n  const mutations = useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);\n  const fetchAll = useQuery<void, string>({\n    queryKey: [\"git\", \"fetch_all\", dir, refreshKey],\n    queryFn: () => invoke(\"cmd_git_fetch_all\", { dir }),\n    refetchInterval: 10 * 60_000,\n  });\n  return [\n    {\n      remotes: useQuery<GitRemote[], string>({\n        queryKey: [\"git\", \"remotes\", dir, refreshKey],\n        queryFn: () => getRemotes(dir),\n        placeholderData: (prev) => prev,\n      }),\n      log: useQuery<GitCommit[], string>({\n        queryKey: [\"git\", \"log\", dir, refreshKey],\n        queryFn: () => invoke(\"cmd_git_log\", { dir }),\n        placeholderData: (prev) => prev,\n      }),\n      status: useQuery<GitStatusSummary, string>({\n        refetchOnMount: true,\n        queryKey: [\"git\", \"status\", dir, refreshKey, fetchAll.dataUpdatedAt],\n        queryFn: () => invoke(\"cmd_git_status\", { dir }),\n        placeholderData: (prev) => prev,\n      }),\n    },\n    mutations,\n  ] as const;\n}\n\nexport const gitMutations = (dir: string, callbacks: GitCallbacks) => {\n  const push = async () => {\n    const remotes = await getRemotes(dir);\n    if (remotes.length === 0) {\n      const remote = await callbacks.addRemote();\n      if (remote == null) throw new Error(\"No remote found\");\n    }\n\n    const result = await invoke<PushResult>(\"cmd_git_push\", { dir });\n    if (result.type !== \"needs_credentials\") return result;\n\n    // Needs credentials, prompt for them\n    const creds = await callbacks.promptCredentials(result);\n    if (creds == null) throw new Error(\"Canceled\");\n\n    await invoke(\"cmd_git_add_credential\", {\n      remoteUrl: result.url,\n      username: creds.username,\n      password: creds.password,\n    });\n\n    // Push again\n    return invoke<PushResult>(\"cmd_git_push\", { dir });\n  };\n\n  const handleError = (err: unknown) => {\n    showToast({\n      id: err instanceof Error ? err.message : String(err),\n      message: err instanceof Error ? err.message : String(err),\n      color: \"danger\",\n      timeout: 5000,\n    });\n  };\n\n  return {\n    init: createFastMutation<void, string, void>({\n      mutationKey: [\"git\", \"init\"],\n      mutationFn: () => invoke(\"cmd_git_initialize\", { dir }),\n      onSuccess,\n    }),\n    add: createFastMutation<void, string, { relaPaths: string[] }>({\n      mutationKey: [\"git\", \"add\", dir],\n      mutationFn: (args) => invoke(\"cmd_git_add\", { dir, ...args }),\n      onSuccess,\n    }),\n    addRemote: createFastMutation<GitRemote, string, GitRemote>({\n      mutationKey: [\"git\", \"add-remote\"],\n      mutationFn: (args) => invoke(\"cmd_git_add_remote\", { dir, ...args }),\n      onSuccess,\n    }),\n    rmRemote: createFastMutation<void, string, { name: string }>({\n      mutationKey: [\"git\", \"rm-remote\", dir],\n      mutationFn: (args) => invoke(\"cmd_git_rm_remote\", { dir, ...args }),\n      onSuccess,\n    }),\n    createBranch: createFastMutation<void, string, { branch: string; base?: string }>({\n      mutationKey: [\"git\", \"branch\", dir],\n      mutationFn: (args) => invoke(\"cmd_git_branch\", { dir, ...args }),\n      onSuccess,\n    }),\n    mergeBranch: createFastMutation<void, string, { branch: string }>({\n      mutationKey: [\"git\", \"merge\", dir],\n      mutationFn: (args) => invoke(\"cmd_git_merge_branch\", { dir, ...args }),\n      onSuccess,\n    }),\n    deleteBranch: createFastMutation<\n      BranchDeleteResult,\n      string,\n      { branch: string; force?: boolean }\n    >({\n      mutationKey: [\"git\", \"delete-branch\", dir],\n      mutationFn: (args) => invoke(\"cmd_git_delete_branch\", { dir, ...args }),\n      onSuccess,\n    }),\n    deleteRemoteBranch: createFastMutation<void, string, { branch: string }>({\n      mutationKey: [\"git\", \"delete-remote-branch\", dir],\n      mutationFn: (args) => invoke(\"cmd_git_delete_remote_branch\", { dir, ...args }),\n      onSuccess,\n    }),\n    renameBranch: createFastMutation<void, string, { oldName: string; newName: string }>({\n      mutationKey: [\"git\", \"rename-branch\", dir],\n      mutationFn: (args) => invoke(\"cmd_git_rename_branch\", { dir, ...args }),\n      onSuccess,\n    }),\n    checkout: createFastMutation<string, string, { branch: string; force: boolean }>({\n      mutationKey: [\"git\", \"checkout\", dir],\n      mutationFn: (args) => invoke(\"cmd_git_checkout\", { dir, ...args }),\n      onSuccess,\n    }),\n    commit: createFastMutation<void, string, { message: string }>({\n      mutationKey: [\"git\", \"commit\", dir],\n      mutationFn: (args) => invoke(\"cmd_git_commit\", { dir, ...args }),\n      onSuccess,\n    }),\n    commitAndPush: createFastMutation<PushResult, string, { message: string }>({\n      mutationKey: [\"git\", \"commit_push\", dir],\n      mutationFn: async (args) => {\n        await invoke(\"cmd_git_commit\", { dir, ...args });\n        return push();\n      },\n      onSuccess,\n    }),\n\n    push: createFastMutation<PushResult, string, void>({\n      mutationKey: [\"git\", \"push\", dir],\n      mutationFn: push,\n      onSuccess,\n    }),\n    pull: createFastMutation<PullResult, string, void>({\n      mutationKey: [\"git\", \"pull\", dir],\n      async mutationFn() {\n        const result = await invoke<PullResult>(\"cmd_git_pull\", { dir });\n\n        if (result.type === \"needs_credentials\") {\n          const creds = await callbacks.promptCredentials(result);\n          if (creds == null) throw new Error(\"Canceled\");\n\n          await invoke(\"cmd_git_add_credential\", {\n            remoteUrl: result.url,\n            username: creds.username,\n            password: creds.password,\n          });\n\n          // Pull again after credentials\n          return invoke<PullResult>(\"cmd_git_pull\", { dir });\n        }\n\n        if (result.type === \"uncommitted_changes\") {\n          void callbacks\n            .promptUncommittedChanges()\n            .then(async (strategy) => {\n              if (strategy === \"cancel\") return;\n\n              await invoke(\"cmd_git_reset_changes\", { dir });\n              return invoke<PullResult>(\"cmd_git_pull\", { dir });\n            })\n            .then(async () => {\n              await onSuccess();\n              await callbacks.forceSync();\n            }, handleError);\n        }\n\n        if (result.type === \"diverged\") {\n          void callbacks\n            .promptDiverged(result)\n            .then((strategy) => {\n              if (strategy === \"cancel\") return;\n\n              if (strategy === \"force_reset\") {\n                return invoke<PullResult>(\"cmd_git_pull_force_reset\", {\n                  dir,\n                  remote: result.remote,\n                  branch: result.branch,\n                });\n              }\n\n              return invoke<PullResult>(\"cmd_git_pull_merge\", {\n                dir,\n                remote: result.remote,\n                branch: result.branch,\n              });\n            })\n            .then(async () => {\n              await onSuccess();\n              await callbacks.forceSync();\n            }, handleError);\n        }\n\n        return result;\n      },\n      onSuccess,\n    }),\n    unstage: createFastMutation<void, string, { relaPaths: string[] }>({\n      mutationKey: [\"git\", \"unstage\", dir],\n      mutationFn: (args) => invoke(\"cmd_git_unstage\", { dir, ...args }),\n      onSuccess,\n    }),\n    resetChanges: createFastMutation<void, string, void>({\n      mutationKey: [\"git\", \"reset-changes\", dir],\n      mutationFn: () => invoke(\"cmd_git_reset_changes\", { dir }),\n      onSuccess,\n    }),\n  } as const;\n};\n\nasync function getRemotes(dir: string) {\n  return invoke<GitRemote[]>(\"cmd_git_remotes\", { dir });\n}\n\n/**\n * Clone a git repository, prompting for credentials if needed.\n */\nexport async function gitClone(\n  url: string,\n  dir: string,\n  promptCredentials: (args: {\n    url: string;\n    error: string | null;\n  }) => Promise<GitCredentials | null>,\n): Promise<CloneResult> {\n  const result = await invoke<CloneResult>(\"cmd_git_clone\", { url, dir });\n  if (result.type !== \"needs_credentials\") return result;\n\n  // Prompt for credentials\n  const creds = await promptCredentials({ url: result.url, error: result.error });\n  if (creds == null) return { type: \"cancelled\" };\n\n  // Store credentials and retry\n  await invoke(\"cmd_git_add_credential\", {\n    remoteUrl: result.url,\n    username: creds.username,\n    password: creds.password,\n  });\n\n  return invoke<CloneResult>(\"cmd_git_clone\", { url, dir });\n}\n"
  },
  {
    "path": "crates/yaak-git/package.json",
    "content": "{\n  \"name\": \"@yaakapp-internal/git\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"index.ts\"\n}\n"
  },
  {
    "path": "crates/yaak-git/src/add.rs",
    "content": "use crate::error::Result;\nuse crate::repository::open_repo;\nuse git2::IndexAddOption;\nuse log::info;\nuse std::path::Path;\n\npub fn git_add(dir: &Path, rela_path: &Path) -> Result<()> {\n    let repo = open_repo(dir)?;\n    let mut index = repo.index()?;\n\n    info!(\"Staging file {rela_path:?} to {dir:?}\");\n    index.add_all(&[rela_path], IndexAddOption::DEFAULT, None)?;\n    index.write()?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/yaak-git/src/binary.rs",
    "content": "use crate::error::Error::GitNotFound;\nuse crate::error::Result;\nuse std::path::Path;\nuse tokio::process::Command;\nuse yaak_common::command::new_checked_command;\n\n/// Create a git command that runs in the specified directory\npub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {\n    let mut cmd = new_binary_command_global().await?;\n    cmd.arg(\"-C\").arg(dir);\n    Ok(cmd)\n}\n\n/// Create a git command without a specific directory (for global operations)\npub(crate) async fn new_binary_command_global() -> Result<Command> {\n    new_checked_command(\"git\", \"--version\").await.map_err(|_| GitNotFound)\n}\n"
  },
  {
    "path": "crates/yaak-git/src/branch.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse ts_rs::TS;\n\nuse crate::binary::new_binary_command;\nuse crate::error::Error::GenericError;\nuse crate::error::Result;\nuse std::path::Path;\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\", tag = \"type\")]\n#[ts(export, export_to = \"gen_git.ts\")]\npub enum BranchDeleteResult {\n    Success { message: String },\n    NotFullyMerged,\n}\n\npub async fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {\n    let branch_name = branch_name.trim_start_matches(\"origin/\");\n\n    let mut args = vec![\"checkout\"];\n    if force {\n        args.push(\"--force\");\n    }\n    args.push(branch_name);\n\n    let out = new_binary_command(dir)\n        .await?\n        .args(&args)\n        .output()\n        .await\n        .map_err(|e| GenericError(format!(\"failed to run git checkout: {e}\")))?;\n\n    let stdout = String::from_utf8_lossy(&out.stdout);\n    let stderr = String::from_utf8_lossy(&out.stderr);\n    let combined = format!(\"{}{}\", stdout, stderr);\n\n    if !out.status.success() {\n        return Err(GenericError(format!(\"Failed to checkout: {}\", combined.trim())));\n    }\n\n    Ok(branch_name.to_string())\n}\n\npub async fn git_create_branch(dir: &Path, name: &str, base: Option<&str>) -> Result<()> {\n    let mut cmd = new_binary_command(dir).await?;\n    cmd.arg(\"branch\").arg(name);\n    if let Some(base_branch) = base {\n        cmd.arg(base_branch);\n    }\n\n    let out =\n        cmd.output().await.map_err(|e| GenericError(format!(\"failed to run git branch: {e}\")))?;\n\n    let stdout = String::from_utf8_lossy(&out.stdout);\n    let stderr = String::from_utf8_lossy(&out.stderr);\n    let combined = format!(\"{}{}\", stdout, stderr);\n\n    if !out.status.success() {\n        return Err(GenericError(format!(\"Failed to create branch: {}\", combined.trim())));\n    }\n\n    Ok(())\n}\n\npub async fn git_delete_branch(dir: &Path, name: &str, force: bool) -> Result<BranchDeleteResult> {\n    let mut cmd = new_binary_command(dir).await?;\n\n    let out =\n        if force { cmd.args([\"branch\", \"-D\", name]) } else { cmd.args([\"branch\", \"-d\", name]) }\n            .output()\n            .await\n            .map_err(|e| GenericError(format!(\"failed to run git branch -d: {e}\")))?;\n\n    let stdout = String::from_utf8_lossy(&out.stdout);\n    let stderr = String::from_utf8_lossy(&out.stderr);\n    let combined = format!(\"{}{}\", stdout, stderr);\n\n    if !out.status.success() && stderr.to_lowercase().contains(\"not fully merged\") {\n        return Ok(BranchDeleteResult::NotFullyMerged);\n    }\n\n    if !out.status.success() {\n        return Err(GenericError(format!(\"Failed to delete branch: {}\", combined.trim())));\n    }\n\n    Ok(BranchDeleteResult::Success { message: combined })\n}\n\npub async fn git_merge_branch(dir: &Path, name: &str) -> Result<()> {\n    let out = new_binary_command(dir)\n        .await?\n        .args([\"merge\", name])\n        .output()\n        .await\n        .map_err(|e| GenericError(format!(\"failed to run git merge: {e}\")))?;\n\n    let stdout = String::from_utf8_lossy(&out.stdout);\n    let stderr = String::from_utf8_lossy(&out.stderr);\n    let combined = format!(\"{}{}\", stdout, stderr);\n\n    if !out.status.success() {\n        // Check for merge conflicts\n        if combined.to_lowercase().contains(\"conflict\") {\n            return Err(GenericError(\n                \"Merge conflicts detected. Please resolve them manually.\".to_string(),\n            ));\n        }\n        return Err(GenericError(format!(\"Failed to merge: {}\", combined.trim())));\n    }\n\n    Ok(())\n}\n\npub async fn git_delete_remote_branch(dir: &Path, name: &str) -> Result<()> {\n    // Remote branch names come in as \"origin/branch-name\", extract the branch name\n    let branch_name = name.trim_start_matches(\"origin/\");\n\n    let out = new_binary_command(dir)\n        .await?\n        .args([\"push\", \"origin\", \"--delete\", branch_name])\n        .output()\n        .await\n        .map_err(|e| GenericError(format!(\"failed to run git push --delete: {e}\")))?;\n\n    let stdout = String::from_utf8_lossy(&out.stdout);\n    let stderr = String::from_utf8_lossy(&out.stderr);\n    let combined = format!(\"{}{}\", stdout, stderr);\n\n    if !out.status.success() {\n        return Err(GenericError(format!(\"Failed to delete remote branch: {}\", combined.trim())));\n    }\n\n    Ok(())\n}\n\npub async fn git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> {\n    let out = new_binary_command(dir)\n        .await?\n        .args([\"branch\", \"-m\", old_name, new_name])\n        .output()\n        .await\n        .map_err(|e| GenericError(format!(\"failed to run git branch -m: {e}\")))?;\n\n    let stdout = String::from_utf8_lossy(&out.stdout);\n    let stderr = String::from_utf8_lossy(&out.stderr);\n    let combined = format!(\"{}{}\", stdout, stderr);\n\n    if !out.status.success() {\n        return Err(GenericError(format!(\"Failed to rename branch: {}\", combined.trim())));\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/yaak-git/src/clone.rs",
    "content": "use crate::binary::new_binary_command;\nuse crate::error::Error::GenericError;\nuse crate::error::Result;\nuse log::info;\nuse serde::{Deserialize, Serialize};\nuse std::fs;\nuse std::path::Path;\nuse ts_rs::TS;\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\", tag = \"type\")]\n#[ts(export, export_to = \"gen_git.ts\")]\npub enum CloneResult {\n    Success,\n    Cancelled,\n    NeedsCredentials { url: String, error: Option<String> },\n}\n\npub async fn git_clone(url: &str, dir: &Path) -> Result<CloneResult> {\n    let parent = dir.parent().ok_or_else(|| GenericError(\"Invalid clone directory\".to_string()))?;\n    fs::create_dir_all(parent)\n        .map_err(|e| GenericError(format!(\"Failed to create directory: {e}\")))?;\n    let mut cmd = new_binary_command(parent).await?;\n    cmd.args([\"clone\", url]).arg(dir).env(\"GIT_TERMINAL_PROMPT\", \"0\");\n\n    let out =\n        cmd.output().await.map_err(|e| GenericError(format!(\"failed to run git clone: {e}\")))?;\n\n    let stdout = String::from_utf8_lossy(&out.stdout);\n    let stderr = String::from_utf8_lossy(&out.stderr);\n    let combined = format!(\"{}{}\", stdout, stderr);\n    let combined_lower = combined.to_lowercase();\n\n    info!(\"Cloned status={}: {combined}\", out.status);\n\n    if !out.status.success() {\n        // Check for credentials error\n        if combined_lower.contains(\"could not read\") {\n            return Ok(CloneResult::NeedsCredentials { url: url.to_string(), error: None });\n        }\n        if combined_lower.contains(\"unable to access\")\n            || combined_lower.contains(\"authentication failed\")\n        {\n            return Ok(CloneResult::NeedsCredentials {\n                url: url.to_string(),\n                error: Some(combined.to_string()),\n            });\n        }\n        return Err(GenericError(format!(\"Failed to clone: {}\", combined.trim())));\n    }\n\n    Ok(CloneResult::Success)\n}\n"
  },
  {
    "path": "crates/yaak-git/src/commit.rs",
    "content": "use crate::binary::new_binary_command;\nuse crate::error::Error::GenericError;\nuse log::info;\nuse std::path::Path;\n\npub async fn git_commit(dir: &Path, message: &str) -> crate::error::Result<()> {\n    let out =\n        new_binary_command(dir).await?.args([\"commit\", \"--message\", message]).output().await?;\n\n    let stdout = String::from_utf8_lossy(&out.stdout);\n    let stderr = String::from_utf8_lossy(&out.stderr);\n    let combined = stdout + stderr;\n\n    if !out.status.success() {\n        return Err(GenericError(format!(\"Failed to commit: {}\", combined)));\n    }\n\n    info!(\"Committed to {dir:?}\");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/yaak-git/src/credential.rs",
    "content": "use crate::binary::new_binary_command_global;\nuse crate::error::Error::GenericError;\nuse crate::error::Result;\nuse std::process::Stdio;\nuse tokio::io::AsyncWriteExt;\nuse url::Url;\n\npub async fn git_add_credential(remote_url: &str, username: &str, password: &str) -> Result<()> {\n    let url = Url::parse(remote_url)\n        .map_err(|e| GenericError(format!(\"Failed to parse remote url {remote_url}: {e:?}\")))?;\n    let protocol = url.scheme();\n    let host = url.host_str().unwrap();\n    let path = Some(url.path());\n\n    let mut child = new_binary_command_global()\n        .await?\n        .args([\"credential\", \"approve\"])\n        .stdin(Stdio::piped())\n        .stdout(Stdio::null())\n        .spawn()?;\n\n    {\n        let stdin = child.stdin.as_mut().unwrap();\n        stdin.write_all(format!(\"protocol={}\\n\", protocol).as_bytes()).await?;\n        stdin.write_all(format!(\"host={}\\n\", host).as_bytes()).await?;\n        if let Some(path) = path {\n            if !path.is_empty() {\n                stdin\n                    .write_all(format!(\"path={}\\n\", path.trim_start_matches('/')).as_bytes())\n                    .await?;\n            }\n        }\n        stdin.write_all(format!(\"username={}\\n\", username).as_bytes()).await?;\n        stdin.write_all(format!(\"password={}\\n\", password).as_bytes()).await?;\n        stdin.write_all(b\"\\n\").await?; // blank line terminator\n    }\n\n    let status = child.wait().await?;\n    if !status.success() {\n        return Err(GenericError(\"Failed to approve git credential\".to_string()));\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/yaak-git/src/error.rs",
    "content": "use serde::{Serialize, Serializer};\nuse std::io;\nuse std::path::PathBuf;\nuse std::string::FromUtf8Error;\nuse thiserror::Error;\n\n#[derive(Error, Debug)]\npub enum Error {\n    #[error(\"Git repo not found {0}\")]\n    GitRepoNotFound(PathBuf),\n\n    #[error(\"Git error: {0}\")]\n    GitUnknown(#[from] git2::Error),\n\n    #[error(\"Yaml error: {0}\")]\n    YamlParseError(#[from] serde_yaml::Error),\n\n    #[error(transparent)]\n    ModelError(#[from] yaak_models::error::Error),\n\n    #[error(\"Sync error: {0}\")]\n    SyncError(#[from] yaak_sync::error::Error),\n\n    #[error(\"I/o error: {0}\")]\n    IoError(#[from] io::Error),\n\n    #[error(\"JSON error: {0}\")]\n    JsonParseError(#[from] serde_json::Error),\n\n    #[error(\"UTF8 error: {0}\")]\n    Utf8ConversionError(#[from] FromUtf8Error),\n\n    #[error(\"Git error: {0}\")]\n    GenericError(String),\n\n    #[error(\"'git' not found. Please ensure it's installed and available in $PATH\")]\n    GitNotFound,\n\n    #[error(\"Credentials required: {0}\")]\n    CredentialsRequiredError(String),\n\n    #[error(\"No default remote found\")]\n    NoDefaultRemoteFound,\n\n    #[error(\"No remotes found for repo\")]\n    NoRemotesFound,\n\n    #[error(\"Merge failed due to conflicts\")]\n    MergeConflicts,\n\n    #[error(\"No active branch\")]\n    NoActiveBranch,\n}\n\nimpl Serialize for Error {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        serializer.serialize_str(self.to_string().as_ref())\n    }\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates/yaak-git/src/fetch.rs",
    "content": "use crate::binary::new_binary_command;\nuse crate::error::Error::GenericError;\nuse crate::error::Result;\nuse std::path::Path;\n\npub async fn git_fetch_all(dir: &Path) -> Result<()> {\n    let out = new_binary_command(dir)\n        .await?\n        .args([\"fetch\", \"--all\", \"--prune\", \"--tags\"])\n        .output()\n        .await\n        .map_err(|e| GenericError(format!(\"failed to run git pull: {e}\")))?;\n    let stdout = String::from_utf8_lossy(&out.stdout);\n    let stderr = String::from_utf8_lossy(&out.stderr);\n    let combined = stdout + stderr;\n\n    if !out.status.success() {\n        return Err(GenericError(format!(\"Failed to fetch: {}\", combined)));\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/yaak-git/src/init.rs",
    "content": "use crate::error::Result;\nuse crate::repository::open_repo;\nuse log::info;\nuse std::path::Path;\n\npub fn git_init(dir: &Path) -> Result<()> {\n    git2::Repository::init(dir)?;\n    let repo = open_repo(dir)?;\n    // Default to main instead of master, to align with\n    // the official Git and GitHub behavior\n    repo.set_head(\"refs/heads/main\")?;\n    info!(\"Initialized {dir:?}\");\n    Ok(())\n}\n"
  },
  {
    "path": "crates/yaak-git/src/lib.rs",
    "content": "mod add;\nmod binary;\nmod branch;\nmod clone;\nmod commit;\nmod credential;\npub mod error;\nmod fetch;\nmod init;\nmod log;\n\nmod pull;\nmod push;\nmod remotes;\nmod repository;\nmod reset;\nmod status;\nmod unstage;\nmod util;\n\n// Re-export all git functions for external use\npub use add::git_add;\npub use branch::{\n    BranchDeleteResult, git_checkout_branch, git_create_branch, git_delete_branch,\n    git_delete_remote_branch, git_merge_branch, git_rename_branch,\n};\npub use clone::{CloneResult, git_clone};\npub use commit::git_commit;\npub use credential::git_add_credential;\npub use fetch::git_fetch_all;\npub use init::git_init;\npub use log::{GitCommit, git_log};\npub use pull::{PullResult, git_pull, git_pull_force_reset, git_pull_merge};\npub use push::{PushResult, git_push};\npub use remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote};\npub use reset::git_reset_changes;\npub use status::{GitStatusSummary, git_status};\npub use unstage::git_unstage;\n"
  },
  {
    "path": "crates/yaak-git/src/log.rs",
    "content": "use crate::repository::open_repo;\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse std::path::Path;\nuse ts_rs::TS;\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_git.ts\")]\npub struct GitCommit {\n    pub author: GitAuthor,\n    pub when: DateTime<Utc>,\n    pub message: Option<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_git.ts\")]\npub struct GitAuthor {\n    pub name: Option<String>,\n    pub email: Option<String>,\n}\n\npub fn git_log(dir: &Path) -> crate::error::Result<Vec<GitCommit>> {\n    let repo = open_repo(dir)?;\n\n    // Return empty if empty repo or no head (new repo)\n    if repo.is_empty()? || repo.head().is_err() {\n        return Ok(vec![]);\n    }\n\n    let mut revwalk = repo.revwalk()?;\n    revwalk.push_head()?;\n    revwalk.set_sorting(git2::Sort::TIME)?;\n\n    // Run git log\n    macro_rules! filter_try {\n        ($e:expr) => {\n            match $e {\n                Ok(t) => t,\n                Err(_) => return None,\n            }\n        };\n    }\n    let log: Vec<GitCommit> = revwalk\n        .filter_map(|oid| {\n            let oid = filter_try!(oid);\n            let commit = filter_try!(repo.find_commit(oid));\n            let author = commit.author();\n            Some(GitCommit {\n                author: GitAuthor {\n                    name: author.name().map(|s| s.to_string()),\n                    email: author.email().map(|s| s.to_string()),\n                },\n                when: convert_git_time_to_date(author.when()),\n                message: commit.message().map(|m| m.to_string()),\n            })\n        })\n        .collect();\n\n    Ok(log)\n}\n\n#[cfg(test)]\nfn convert_git_time_to_date(_git_time: git2::Time) -> DateTime<Utc> {\n    DateTime::from_timestamp(0, 0).unwrap()\n}\n\n#[cfg(not(test))]\nfn convert_git_time_to_date(git_time: git2::Time) -> DateTime<Utc> {\n    let timestamp = git_time.seconds();\n    DateTime::from_timestamp(timestamp, 0).unwrap()\n}\n"
  },
  {
    "path": "crates/yaak-git/src/pull.rs",
    "content": "use crate::binary::new_binary_command;\nuse crate::error::Error::GenericError;\nuse crate::error::Result;\nuse crate::repository::open_repo;\nuse crate::util::{get_current_branch_name, get_default_remote_in_repo};\nuse log::info;\nuse serde::{Deserialize, Serialize};\nuse std::path::Path;\nuse ts_rs::TS;\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\", tag = \"type\")]\n#[ts(export, export_to = \"gen_git.ts\")]\npub enum PullResult {\n    Success { message: String },\n    UpToDate,\n    NeedsCredentials { url: String, error: Option<String> },\n    Diverged { remote: String, branch: String },\n    UncommittedChanges,\n}\n\nfn has_uncommitted_changes(dir: &Path) -> Result<bool> {\n    let repo = open_repo(dir)?;\n    let mut opts = git2::StatusOptions::new();\n    opts.include_ignored(false).include_untracked(false);\n    let statuses = repo.statuses(Some(&mut opts))?;\n    Ok(statuses.iter().any(|e| e.status() != git2::Status::CURRENT))\n}\n\npub async fn git_pull(dir: &Path) -> Result<PullResult> {\n    if has_uncommitted_changes(dir)? {\n        return Ok(PullResult::UncommittedChanges);\n    }\n\n    // Extract all git2 data before any await points (git2 types are not Send)\n    let (branch_name, remote_name, remote_url) = {\n        let repo = open_repo(dir)?;\n        let branch_name = get_current_branch_name(&repo)?;\n        let remote = get_default_remote_in_repo(&repo)?;\n        let remote_name =\n            remote.name().ok_or(GenericError(\"Failed to get remote name\".to_string()))?.to_string();\n        let remote_url =\n            remote.url().ok_or(GenericError(\"Failed to get remote url\".to_string()))?.to_string();\n        (branch_name, remote_name, remote_url)\n    };\n\n    // Step 1: fetch the specific branch\n    // NOTE: We use fetch + merge instead of `git pull` to avoid conflicts with\n    // global git config (e.g. pull.ff=only) and the background fetch --all.\n    let fetch_out = new_binary_command(dir)\n        .await?\n        .args([\"fetch\", &remote_name, &branch_name])\n        .env(\"GIT_TERMINAL_PROMPT\", \"0\")\n        .output()\n        .await\n        .map_err(|e| GenericError(format!(\"failed to run git fetch: {e}\")))?;\n\n    let fetch_stdout = String::from_utf8_lossy(&fetch_out.stdout);\n    let fetch_stderr = String::from_utf8_lossy(&fetch_out.stderr);\n    let fetch_combined = format!(\"{fetch_stdout}{fetch_stderr}\");\n\n    info!(\"Fetched status={} {fetch_combined}\", fetch_out.status);\n\n    if fetch_combined.to_lowercase().contains(\"could not read\") {\n        return Ok(PullResult::NeedsCredentials { url: remote_url.to_string(), error: None });\n    }\n\n    if fetch_combined.to_lowercase().contains(\"unable to access\") {\n        return Ok(PullResult::NeedsCredentials {\n            url: remote_url.to_string(),\n            error: Some(fetch_combined.to_string()),\n        });\n    }\n\n    if !fetch_out.status.success() {\n        return Err(GenericError(format!(\"Failed to fetch: {fetch_combined}\")));\n    }\n\n    // Step 2: merge the fetched branch\n    let ref_name = format!(\"{}/{}\", remote_name, branch_name);\n    let merge_out = new_binary_command(dir)\n        .await?\n        .args([\"merge\", \"--ff-only\", &ref_name])\n        .output()\n        .await\n        .map_err(|e| GenericError(format!(\"failed to run git merge: {e}\")))?;\n\n    let merge_stdout = String::from_utf8_lossy(&merge_out.stdout);\n    let merge_stderr = String::from_utf8_lossy(&merge_out.stderr);\n    let merge_combined = format!(\"{merge_stdout}{merge_stderr}\");\n\n    info!(\"Merged status={} {merge_combined}\", merge_out.status);\n\n    if !merge_out.status.success() {\n        let merge_lower = merge_combined.to_lowercase();\n        if merge_lower.contains(\"cannot fast-forward\")\n            || merge_lower.contains(\"not possible to fast-forward\")\n            || merge_lower.contains(\"diverged\")\n        {\n            return Ok(PullResult::Diverged { remote: remote_name, branch: branch_name });\n        }\n        return Err(GenericError(format!(\"Failed to merge: {merge_combined}\")));\n    }\n\n    if merge_combined.to_lowercase().contains(\"up to date\") {\n        return Ok(PullResult::UpToDate);\n    }\n\n    Ok(PullResult::Success { message: format!(\"Pulled from {}/{}\", remote_name, branch_name) })\n}\n\npub async fn git_pull_force_reset(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {\n    // Step 1: fetch the remote\n    let fetch_out = new_binary_command(dir)\n        .await?\n        .args([\"fetch\", remote])\n        .env(\"GIT_TERMINAL_PROMPT\", \"0\")\n        .output()\n        .await\n        .map_err(|e| GenericError(format!(\"failed to run git fetch: {e}\")))?;\n\n    if !fetch_out.status.success() {\n        let stderr = String::from_utf8_lossy(&fetch_out.stderr);\n        return Err(GenericError(format!(\"Failed to fetch: {stderr}\")));\n    }\n\n    // Step 2: reset --hard to remote/branch\n    let ref_name = format!(\"{}/{}\", remote, branch);\n    let reset_out = new_binary_command(dir)\n        .await?\n        .args([\"reset\", \"--hard\", &ref_name])\n        .output()\n        .await\n        .map_err(|e| GenericError(format!(\"failed to run git reset: {e}\")))?;\n\n    if !reset_out.status.success() {\n        let stderr = String::from_utf8_lossy(&reset_out.stderr);\n        return Err(GenericError(format!(\"Failed to reset: {}\", stderr.trim())));\n    }\n\n    Ok(PullResult::Success { message: format!(\"Reset to {}/{}\", remote, branch) })\n}\n\npub async fn git_pull_merge(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {\n    let out = new_binary_command(dir)\n        .await?\n        .args([\"pull\", \"--no-rebase\", remote, branch])\n        .env(\"GIT_TERMINAL_PROMPT\", \"0\")\n        .output()\n        .await\n        .map_err(|e| GenericError(format!(\"failed to run git pull --no-rebase: {e}\")))?;\n\n    let stdout = String::from_utf8_lossy(&out.stdout);\n    let stderr = String::from_utf8_lossy(&out.stderr);\n    let combined = format!(\"{}{}\", stdout, stderr);\n\n    info!(\"Pull merge status={} {combined}\", out.status);\n\n    if !out.status.success() {\n        if combined.to_lowercase().contains(\"conflict\") {\n            return Err(GenericError(\n                \"Merge conflicts detected. Please resolve them manually.\".to_string(),\n            ));\n        }\n        return Err(GenericError(format!(\"Failed to merge pull: {}\", combined.trim())));\n    }\n\n    Ok(PullResult::Success { message: format!(\"Merged from {}/{}\", remote, branch) })\n}\n\n// pub(crate) fn git_pull_old(dir: &Path) -> Result<PullResult> {\n//     let repo = open_repo(dir)?;\n//\n//     let branch = get_current_branch(&repo)?.ok_or(NoActiveBranch)?;\n//     let branch_ref = branch.get();\n//     let branch_ref = bytes_to_string(branch_ref.name_bytes())?;\n//\n//     let remote_name = repo.branch_upstream_remote(&branch_ref)?;\n//     let remote_name = bytes_to_string(&remote_name)?;\n//     debug!(\"Pulling from {remote_name}\");\n//\n//     let mut remote = repo.find_remote(&remote_name)?;\n//\n//     let mut options = FetchOptions::new();\n//     let callbacks = default_callbacks();\n//     options.remote_callbacks(callbacks);\n//\n//     let mut proxy = ProxyOptions::new();\n//     proxy.auto();\n//     options.proxy_options(proxy);\n//\n//     remote.fetch(&[&branch_ref], Some(&mut options), None)?;\n//\n//     let stats = remote.stats();\n//\n//     let fetch_head = repo.find_reference(\"FETCH_HEAD\")?;\n//     let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;\n//     do_merge(&repo, &branch, &fetch_commit)?;\n//\n//     Ok(PullResult::Success {\n//         message: \"Hello\".to_string(),\n//         // received_bytes: stats.received_bytes(),\n//         // received_objects: stats.received_objects(),\n//     })\n// }\n"
  },
  {
    "path": "crates/yaak-git/src/push.rs",
    "content": "use crate::binary::new_binary_command;\nuse crate::error::Error::GenericError;\nuse crate::error::Result;\nuse crate::repository::open_repo;\nuse crate::util::{get_current_branch_name, get_default_remote_for_push_in_repo};\nuse log::info;\nuse serde::{Deserialize, Serialize};\nuse std::path::Path;\nuse ts_rs::TS;\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\", tag = \"type\")]\n#[ts(export, export_to = \"gen_git.ts\")]\npub enum PushResult {\n    Success { message: String },\n    UpToDate,\n    NeedsCredentials { url: String, error: Option<String> },\n}\n\npub async fn git_push(dir: &Path) -> Result<PushResult> {\n    // Extract all git2 data before any await points (git2 types are not Send)\n    let (branch_name, remote_name, remote_url) = {\n        let repo = open_repo(dir)?;\n        let branch_name = get_current_branch_name(&repo)?;\n        let remote = get_default_remote_for_push_in_repo(&repo)?;\n        let remote_name =\n            remote.name().ok_or(GenericError(\"Failed to get remote name\".to_string()))?.to_string();\n        let remote_url =\n            remote.url().ok_or(GenericError(\"Failed to get remote url\".to_string()))?.to_string();\n        (branch_name, remote_name, remote_url)\n    };\n\n    let out = new_binary_command(dir)\n        .await?\n        .args([\"push\", &remote_name, &branch_name])\n        .env(\"GIT_TERMINAL_PROMPT\", \"0\")\n        .output()\n        .await\n        .map_err(|e| GenericError(format!(\"failed to run git push: {e}\")))?;\n\n    let stdout = String::from_utf8_lossy(&out.stdout);\n    let stderr = String::from_utf8_lossy(&out.stderr);\n    let combined = stdout + stderr;\n    let combined_lower = combined.to_lowercase();\n\n    info!(\"Pushed to repo status={} {combined}\", out.status);\n\n    // Helper to check if this is a credentials error\n    let is_credentials_error = || {\n        combined_lower.contains(\"could not read\")\n            || combined_lower.contains(\"unable to access\")\n            || combined_lower.contains(\"authentication failed\")\n    };\n\n    // Check for explicit rejection indicators first (e.g., protected branch rejections)\n    // These can occur even if some git servers don't properly set exit codes\n    if combined_lower.contains(\"rejected\") || combined_lower.contains(\"failed to push\") {\n        if is_credentials_error() {\n            return Ok(PushResult::NeedsCredentials {\n                url: remote_url.to_string(),\n                error: Some(combined.to_string()),\n            });\n        }\n        return Err(GenericError(format!(\"Failed to push: {combined}\")));\n    }\n\n    // Check exit status for any other failures\n    if !out.status.success() {\n        if combined_lower.contains(\"could not read\") {\n            return Ok(PushResult::NeedsCredentials { url: remote_url.to_string(), error: None });\n        }\n        if combined_lower.contains(\"unable to access\")\n            || combined_lower.contains(\"authentication failed\")\n        {\n            return Ok(PushResult::NeedsCredentials {\n                url: remote_url.to_string(),\n                error: Some(combined.to_string()),\n            });\n        }\n        return Err(GenericError(format!(\"Failed to push: {combined}\")));\n    }\n\n    // Success cases (exit code 0 and no rejection indicators)\n    if combined_lower.contains(\"up-to-date\") {\n        return Ok(PushResult::UpToDate);\n    }\n\n    Ok(PushResult::Success { message: format!(\"Pushed to {}/{}\", remote_name, branch_name) })\n}\n"
  },
  {
    "path": "crates/yaak-git/src/remotes.rs",
    "content": "use crate::error::Result;\nuse crate::repository::open_repo;\nuse log::warn;\nuse serde::{Deserialize, Serialize};\nuse std::path::Path;\nuse ts_rs::TS;\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]\n#[ts(export, export_to = \"gen_git.ts\")]\npub struct GitRemote {\n    name: String,\n    url: Option<String>,\n}\n\npub fn git_remotes(dir: &Path) -> Result<Vec<GitRemote>> {\n    let repo = open_repo(dir)?;\n    let mut remotes = Vec::new();\n\n    for remote in repo.remotes()?.into_iter() {\n        let name = match remote {\n            None => continue,\n            Some(name) => name,\n        };\n        let r = match repo.find_remote(name) {\n            Ok(r) => r,\n            Err(e) => {\n                warn!(\"Failed to get remote {name}: {e:?}\");\n                continue;\n            }\n        };\n        remotes.push(GitRemote { name: name.to_string(), url: r.url().map(|u| u.to_string()) });\n    }\n\n    Ok(remotes)\n}\n\npub fn git_add_remote(dir: &Path, name: &str, url: &str) -> Result<GitRemote> {\n    let repo = open_repo(dir)?;\n    repo.remote(name, url)?;\n    Ok(GitRemote { name: name.to_string(), url: Some(url.to_string()) })\n}\n\npub fn git_rm_remote(dir: &Path, name: &str) -> Result<()> {\n    let repo = open_repo(dir)?;\n    repo.remote_delete(name)?;\n    Ok(())\n}\n"
  },
  {
    "path": "crates/yaak-git/src/repository.rs",
    "content": "use crate::error::Error::{GitRepoNotFound, GitUnknown};\nuse std::path::Path;\n\npub(crate) fn open_repo(dir: &Path) -> crate::error::Result<git2::Repository> {\n    match git2::Repository::discover(dir) {\n        Ok(r) => Ok(r),\n        Err(e) if e.code() == git2::ErrorCode::NotFound => Err(GitRepoNotFound(dir.to_path_buf())),\n        Err(e) => Err(GitUnknown(e)),\n    }\n}\n"
  },
  {
    "path": "crates/yaak-git/src/reset.rs",
    "content": "use crate::binary::new_binary_command;\nuse crate::error::Error::GenericError;\nuse crate::error::Result;\nuse std::path::Path;\n\npub async fn git_reset_changes(dir: &Path) -> Result<()> {\n    let out = new_binary_command(dir)\n        .await?\n        .args([\"reset\", \"--hard\", \"HEAD\"])\n        .output()\n        .await\n        .map_err(|e| GenericError(format!(\"failed to run git reset: {e}\")))?;\n\n    if !out.status.success() {\n        let stderr = String::from_utf8_lossy(&out.stderr);\n        return Err(GenericError(format!(\"Failed to reset: {}\", stderr.trim())));\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/yaak-git/src/status.rs",
    "content": "use crate::repository::open_repo;\nuse crate::util::{local_branch_names, remote_branch_names};\nuse log::warn;\nuse serde::{Deserialize, Serialize};\nuse std::fs;\nuse std::path::Path;\nuse ts_rs::TS;\nuse yaak_sync::models::SyncModel;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_git.ts\")]\npub struct GitStatusSummary {\n    pub path: String,\n    pub head_ref: Option<String>,\n    pub head_ref_shorthand: Option<String>,\n    pub entries: Vec<GitStatusEntry>,\n    pub origins: Vec<String>,\n    pub local_branches: Vec<String>,\n    pub remote_branches: Vec<String>,\n    pub ahead: u32,\n    pub behind: u32,\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_git.ts\")]\npub struct GitStatusEntry {\n    pub rela_path: String,\n    pub status: GitStatus,\n    pub staged: bool,\n    pub prev: Option<SyncModel>,\n    pub next: Option<SyncModel>,\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_git.ts\")]\npub enum GitStatus {\n    Untracked,\n    Conflict,\n    Current,\n    Modified,\n    Removed,\n    Renamed,\n    TypeChange,\n}\n\npub fn git_status(dir: &Path) -> crate::error::Result<GitStatusSummary> {\n    let repo = open_repo(dir)?;\n    let (head_tree, head_ref, head_ref_shorthand) = match repo.head() {\n        Ok(head) => {\n            let tree = head.peel_to_tree().ok();\n            let head_ref_shorthand = head.shorthand().map(|s| s.to_string());\n            let head_ref = head.name().map(|s| s.to_string());\n\n            (tree, head_ref, head_ref_shorthand)\n        }\n        Err(_) => {\n            // For \"unborn\" repos, reading from HEAD is the only way to get the branch name\n            // See https://github.com/starship/starship/pull/1336\n            let head_path = repo.path().join(\"HEAD\");\n            let head_ref = fs::read_to_string(&head_path)\n                .ok()\n                .unwrap_or_default()\n                .lines()\n                .next()\n                .map(|s| s.trim_start_matches(\"ref:\").trim().to_string());\n            let head_ref_shorthand =\n                head_ref.clone().map(|r| r.split('/').last().unwrap_or(\"unknown\").to_string());\n            (None, head_ref, head_ref_shorthand)\n        }\n    };\n\n    let mut opts = git2::StatusOptions::new();\n    opts.include_ignored(false)\n        .include_untracked(true) // Include untracked\n        .recurse_untracked_dirs(true) // Show all untracked\n        .include_unmodified(true); // Include unchanged\n\n    // TODO: Support renames\n\n    let mut entries: Vec<GitStatusEntry> = Vec::new();\n    for entry in repo.statuses(Some(&mut opts))?.into_iter() {\n        let rela_path = entry.path().unwrap().to_string();\n        let status = entry.status();\n        let index_status = match status {\n            // Note: order matters here, since we're checking a bitmap!\n            s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,\n            s if s.contains(git2::Status::INDEX_NEW) => GitStatus::Untracked,\n            s if s.contains(git2::Status::INDEX_MODIFIED) => GitStatus::Modified,\n            s if s.contains(git2::Status::INDEX_DELETED) => GitStatus::Removed,\n            s if s.contains(git2::Status::INDEX_RENAMED) => GitStatus::Renamed,\n            s if s.contains(git2::Status::INDEX_TYPECHANGE) => GitStatus::TypeChange,\n            s if s.contains(git2::Status::CURRENT) => GitStatus::Current,\n            s => {\n                warn!(\"Unknown index status {s:?}\");\n                continue;\n            }\n        };\n\n        let worktree_status = match status {\n            // Note: order matters here, since we're checking a bitmap!\n            s if s.contains(git2::Status::CONFLICTED) => GitStatus::Conflict,\n            s if s.contains(git2::Status::WT_NEW) => GitStatus::Untracked,\n            s if s.contains(git2::Status::WT_MODIFIED) => GitStatus::Modified,\n            s if s.contains(git2::Status::WT_DELETED) => GitStatus::Removed,\n            s if s.contains(git2::Status::WT_RENAMED) => GitStatus::Renamed,\n            s if s.contains(git2::Status::WT_TYPECHANGE) => GitStatus::TypeChange,\n            s if s.contains(git2::Status::CURRENT) => GitStatus::Current,\n            s => {\n                warn!(\"Unknown worktree status {s:?}\");\n                continue;\n            }\n        };\n\n        let status = if index_status == GitStatus::Current {\n            worktree_status.clone()\n        } else {\n            index_status.clone()\n        };\n\n        let staged = if index_status == GitStatus::Current && worktree_status == GitStatus::Current\n        {\n            // No change, so can't be added\n            false\n        } else if index_status != GitStatus::Current {\n            true\n        } else {\n            false\n        };\n\n        // Get previous content from Git, if it's in there\n        let prev = match head_tree.clone() {\n            None => None,\n            Some(t) => match t.get_path(&Path::new(&rela_path)) {\n                Ok(entry) => {\n                    let obj = entry.to_object(&repo)?;\n                    let content = obj.as_blob().unwrap().content();\n                    let name = Path::new(entry.name().unwrap_or_default());\n                    SyncModel::from_bytes(content.into(), name)?.map(|m| m.0)\n                }\n                Err(_) => None,\n            },\n        };\n\n        let next = {\n            let full_path = repo.workdir().unwrap().join(rela_path.clone());\n            SyncModel::from_file(full_path.as_path())?.map(|m| m.0)\n        };\n\n        entries.push(GitStatusEntry {\n            status,\n            staged,\n            rela_path,\n            prev: prev.clone(),\n            next: next.clone(),\n        })\n    }\n\n    let origins = repo.remotes()?.into_iter().filter_map(|o| Some(o?.to_string())).collect();\n    let local_branches = local_branch_names(&repo)?;\n    let remote_branches = remote_branch_names(&repo)?;\n\n    // Compute ahead/behind relative to remote tracking branch\n    let (ahead, behind) = (|| -> Option<(usize, usize)> {\n        let head = repo.head().ok()?;\n        let local_oid = head.target()?;\n        let branch_name = head.shorthand()?;\n        let upstream_ref =\n            repo.find_branch(&format!(\"origin/{branch_name}\"), git2::BranchType::Remote).ok()?;\n        let upstream_oid = upstream_ref.get().target()?;\n        repo.graph_ahead_behind(local_oid, upstream_oid).ok()\n    })()\n    .unwrap_or((0, 0));\n\n    Ok(GitStatusSummary {\n        entries,\n        origins,\n        path: dir.to_string_lossy().to_string(),\n        head_ref,\n        head_ref_shorthand,\n        local_branches,\n        remote_branches,\n        ahead: ahead as u32,\n        behind: behind as u32,\n    })\n}\n"
  },
  {
    "path": "crates/yaak-git/src/unstage.rs",
    "content": "use crate::repository::open_repo;\nuse log::info;\nuse std::path::Path;\n\npub fn git_unstage(dir: &Path, rela_path: &Path) -> crate::error::Result<()> {\n    let repo = open_repo(dir)?;\n\n    let head = match repo.head() {\n        Ok(h) => h,\n        Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {\n            info!(\"Unstaging file in empty branch {rela_path:?} to {dir:?}\");\n            // Repo has no commits, so \"unstage\" means remove from index\n            let mut index = repo.index()?;\n            index.remove_path(rela_path)?;\n            index.write()?;\n            return Ok(());\n        }\n        Err(e) => return Err(e.into()),\n    };\n\n    // If repo has commits, update the index entry back to HEAD\n    info!(\"Unstaging file {rela_path:?} to {dir:?}\");\n    let commit = head.peel_to_commit()?;\n    repo.reset_default(Some(commit.as_object()), &[rela_path])?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/yaak-git/src/util.rs",
    "content": "use crate::error::Error::{GenericError, NoDefaultRemoteFound};\nuse crate::error::Result;\nuse git2::{Branch, BranchType, Remote, Repository};\n\nconst DEFAULT_REMOTE_NAME: &str = \"origin\";\n\npub(crate) fn get_current_branch(repo: &Repository) -> Result<Option<Branch<'_>>> {\n    for b in repo.branches(None)? {\n        let branch = b?.0;\n        if branch.is_head() {\n            return Ok(Some(branch));\n        }\n    }\n    Ok(None)\n}\n\npub(crate) fn get_current_branch_name(repo: &Repository) -> Result<String> {\n    Ok(get_current_branch(&repo)?\n        .ok_or(GenericError(\"Failed to get current branch\".to_string()))?\n        .name()?\n        .ok_or(GenericError(\"Failed to get current branch name\".to_string()))?\n        .to_string())\n}\n\npub(crate) fn local_branch_names(repo: &Repository) -> Result<Vec<String>> {\n    let mut branches = Vec::new();\n    for branch in repo.branches(Some(BranchType::Local))? {\n        let (branch, _) = branch?;\n        let name = branch.name_bytes()?;\n        let name = bytes_to_string(name)?;\n        branches.push(name);\n    }\n    Ok(branches)\n}\n\npub(crate) fn remote_branch_names(repo: &Repository) -> Result<Vec<String>> {\n    let mut branches = Vec::new();\n    for branch in repo.branches(Some(BranchType::Remote))? {\n        let (branch, _) = branch?;\n        let name = branch.name_bytes()?;\n        let name = bytes_to_string(name)?;\n        if name.ends_with(\"/HEAD\") {\n            continue;\n        }\n        branches.push(name);\n    }\n    Ok(branches)\n}\n\npub(crate) fn bytes_to_string(bytes: &[u8]) -> Result<String> {\n    Ok(String::from_utf8(bytes.to_vec())?)\n}\n\npub(crate) fn get_default_remote_for_push_in_repo(repo: &'_ Repository) -> Result<Remote<'_>> {\n    let name = get_default_remote_name_for_push_in_repo(repo)?;\n    let remote = repo.find_remote(&name)?;\n    Ok(remote)\n}\n\npub(crate) fn get_default_remote_name_for_push_in_repo(repo: &Repository) -> Result<String> {\n    let config = repo.config()?;\n\n    let branch = get_current_branch(repo)?;\n\n    if let Some(branch) = branch {\n        let remote_name = bytes_to_string(branch.name_bytes()?)?;\n\n        let entry_name = format!(\"branch.{}.pushRemote\", &remote_name);\n\n        if let Ok(entry) = config.get_entry(&entry_name) {\n            return bytes_to_string(entry.value_bytes());\n        }\n\n        if let Ok(entry) = config.get_entry(\"remote.pushDefault\") {\n            return bytes_to_string(entry.value_bytes());\n        }\n\n        let entry_name = format!(\"branch.{}.remote\", &remote_name);\n\n        if let Ok(entry) = config.get_entry(&entry_name) {\n            return bytes_to_string(entry.value_bytes());\n        }\n    }\n\n    get_default_remote_name_in_repo(repo)\n}\n\npub(crate) fn get_default_remote_in_repo(repo: &'_ Repository) -> Result<Remote<'_>> {\n    let name = get_default_remote_name_in_repo(repo)?;\n    let remote = repo.find_remote(&name)?;\n    Ok(remote)\n}\n\npub(crate) fn get_default_remote_name_in_repo(repo: &Repository) -> Result<String> {\n    let remotes = repo.remotes()?;\n\n    if remotes.is_empty() {\n        return Err(NoDefaultRemoteFound);\n    }\n\n    // if `origin` exists return that\n    let found_origin = remotes.iter().any(|r| r.is_some_and(|r| r == DEFAULT_REMOTE_NAME));\n    if found_origin {\n        return Ok(DEFAULT_REMOTE_NAME.into());\n    }\n\n    // if only one remote exists, pick that\n    if remotes.len() == 1 {\n        let first_remote = remotes\n            .iter()\n            .next()\n            .flatten()\n            .map(String::from)\n            .ok_or_else(|| GenericError(\"no remote found\".into()))?;\n\n        return Ok(first_remote);\n    }\n\n    // inconclusive\n    Err(NoDefaultRemoteFound)\n}\n"
  },
  {
    "path": "crates/yaak-grpc/Cargo.toml",
    "content": "[package]\nname = \"yaak-grpc\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nanyhow = \"1.0.97\"\nasync-recursion = \"1.1.1\"\ndunce = \"1.0.4\"\nhyper-rustls = { version = \"0.27.7\", default-features = false, features = [\"http2\"] }\nhyper-util = { version = \"0.1.13\", default-features = false, features = [\"client-legacy\"] }\nlog = { workspace = true }\nmd5 = \"0.7.0\"\nprost = \"0.13.4\"\nprost-reflect = { version = \"0.14.4\", default-features = false, features = [\"serde\", \"derive\"] }\nprost-types = \"0.13.4\"\nserde = { workspace = true, features = [\"derive\"] }\nserde_json = { workspace = true }\ntokio = { workspace = true, features = [\"macros\", \"rt-multi-thread\", \"fs\", \"process\"] }\ntokio-stream = \"0.1.14\"\ntonic = { version = \"0.12.3\", default-features = false, features = [\"transport\"] }\ntonic-reflection = \"0.12.3\"\nuuid = { version = \"1.7.0\", features = [\"v4\"] }\nyaak-common = { workspace = true }\nyaak-tls = { workspace = true }\nthiserror = \"2.0.17\"\n"
  },
  {
    "path": "crates/yaak-grpc/src/any.rs",
    "content": "use log::error;\n\npub(crate) fn collect_any_types(json: &str, out: &mut Vec<String>) {\n    let value = match serde_json::from_str(json).map_err(|e| e.to_string()) {\n        Ok(v) => v,\n        Err(e) => {\n            error!(\"Failed to parse gRPC message JSON: {e:?}\");\n            return;\n        }\n    };\n    collect_any_types_value(&value, out);\n}\n\nfn collect_any_types_value(json: &serde_json::Value, out: &mut Vec<String>) {\n    match json {\n        serde_json::Value::Object(map) => {\n            if let Some(t) = map.get(\"@type\").and_then(|v| v.as_str()) {\n                if let Some(full_name) = t.rsplit_once('/').map(|(_, n)| n) {\n                    out.push(full_name.to_string());\n                }\n            }\n\n            for v in map.values() {\n                collect_any_types_value(v, out);\n            }\n        }\n        serde_json::Value::Array(arr) => {\n            for v in arr {\n                collect_any_types_value(v, out);\n            }\n        }\n        _ => {}\n    }\n}\n\n// Write tests for this\n#[cfg(test)]\nmod tests {\n    #[test]\n    fn test_collect_any_types() {\n        let json = r#\"{\n            \"mounts\": [\n              {\n                \"mountSource\": {\n                  \"@type\": \"type.googleapis.com/mount_source.MountSourceRBDVolume\",\n                  \"volumeID\": \"volumes/rbd\"\n                }\n              }\n            ],\n            \"foo\": {\n              \"@type\": \"type.googleapis.com/foo.bar\",\n              \"foo\": \"fooo\"\n            }\n        }\"#;\n\n        let mut out = Vec::new();\n        super::collect_any_types(json, &mut out);\n        out.sort();\n        assert_eq!(out, vec![\"foo.bar\", \"mount_source.MountSourceRBDVolume\"]);\n    }\n}\n"
  },
  {
    "path": "crates/yaak-grpc/src/client.rs",
    "content": "use crate::error::Error::GenericError;\nuse crate::error::Result;\nuse crate::manager::decorate_req;\nuse crate::transport::get_transport;\nuse async_recursion::async_recursion;\nuse hyper_rustls::HttpsConnector;\nuse hyper_util::client::legacy::Client;\nuse hyper_util::client::legacy::connect::HttpConnector;\nuse log::debug;\nuse std::collections::BTreeMap;\nuse tokio_stream::StreamExt;\nuse tonic::Request;\nuse tonic::body::BoxBody;\nuse tonic::transport::Uri;\nuse tonic_reflection::pb::v1::server_reflection_request::MessageRequest;\nuse tonic_reflection::pb::v1::server_reflection_response::MessageResponse;\nuse tonic_reflection::pb::v1::{\n    ErrorResponse, ExtensionNumberResponse, ListServiceResponse, ServerReflectionRequest,\n    ServiceResponse,\n};\nuse tonic_reflection::pb::v1::{ExtensionRequest, FileDescriptorResponse};\nuse tonic_reflection::pb::{v1, v1alpha};\nuse yaak_tls::ClientCertificateConfig;\n\npub struct AutoReflectionClient<T = Client<HttpsConnector<HttpConnector>, BoxBody>> {\n    use_v1alpha: bool,\n    client_v1: v1::server_reflection_client::ServerReflectionClient<T>,\n    client_v1alpha: v1alpha::server_reflection_client::ServerReflectionClient<T>,\n}\n\nimpl AutoReflectionClient {\n    pub fn new(\n        uri: &Uri,\n        validate_certificates: bool,\n        client_cert: Option<ClientCertificateConfig>,\n    ) -> Result<Self> {\n        let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin(\n            get_transport(validate_certificates, client_cert.clone())?,\n            uri.clone(),\n        );\n        let client_v1alpha = v1alpha::server_reflection_client::ServerReflectionClient::with_origin(\n            get_transport(validate_certificates, client_cert.clone())?,\n            uri.clone(),\n        );\n        Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha })\n    }\n\n    #[async_recursion]\n    pub async fn send_reflection_request(\n        &mut self,\n        message: MessageRequest,\n        metadata: &BTreeMap<String, String>,\n    ) -> Result<MessageResponse> {\n        let reflection_request = ServerReflectionRequest {\n            host: \"\".into(), // Doesn't matter\n            message_request: Some(message.clone()),\n        };\n\n        if self.use_v1alpha {\n            let mut request =\n                Request::new(tokio_stream::once(to_v1alpha_request(reflection_request)));\n            decorate_req(metadata, &mut request)?;\n\n            self.client_v1alpha\n                .server_reflection_info(request)\n                .await\n                .map_err(|e| match e.code() {\n                    tonic::Code::Unavailable => {\n                        GenericError(\"Failed to connect to endpoint\".to_string())\n                    }\n                    tonic::Code::Unauthenticated => {\n                        GenericError(\"Authentication failed\".to_string())\n                    }\n                    tonic::Code::DeadlineExceeded => GenericError(\"Deadline exceeded\".to_string()),\n                    _ => GenericError(e.to_string()),\n                })?\n                .into_inner()\n                .next()\n                .await\n                .ok_or(GenericError(\"Missing reflection message\".to_string()))??\n                .message_response\n                .ok_or(GenericError(\"No reflection response\".to_string()))\n                .map(|resp| to_v1_msg_response(resp))\n        } else {\n            let mut request = Request::new(tokio_stream::once(reflection_request));\n            decorate_req(metadata, &mut request)?;\n\n            let resp = self.client_v1.server_reflection_info(request).await;\n            match resp {\n                Ok(r) => Ok(r),\n                Err(e) => match e.code().clone() {\n                    tonic::Code::Unimplemented => {\n                        // If v1 fails, change to v1alpha and try again\n                        debug!(\"gRPC schema reflection falling back to v1alpha\");\n                        self.use_v1alpha = true;\n                        return self.send_reflection_request(message, metadata).await;\n                    }\n                    _ => Err(e),\n                },\n            }\n            .map_err(|e| match e.code() {\n                tonic::Code::Unavailable => {\n                    GenericError(\"Failed to connect to endpoint\".to_string())\n                }\n                tonic::Code::Unauthenticated => GenericError(\"Authentication failed\".to_string()),\n                tonic::Code::DeadlineExceeded => GenericError(\"Deadline exceeded\".to_string()),\n                _ => GenericError(e.to_string()),\n            })?\n            .into_inner()\n            .next()\n            .await\n            .ok_or(GenericError(\"Missing reflection message\".to_string()))??\n            .message_response\n            .ok_or(GenericError(\"No reflection response\".to_string()))\n        }\n    }\n}\n\nfn to_v1_msg_response(\n    response: v1alpha::server_reflection_response::MessageResponse,\n) -> MessageResponse {\n    match response {\n        v1alpha::server_reflection_response::MessageResponse::FileDescriptorResponse(v) => {\n            MessageResponse::FileDescriptorResponse(FileDescriptorResponse {\n                file_descriptor_proto: v.file_descriptor_proto,\n            })\n        }\n        v1alpha::server_reflection_response::MessageResponse::AllExtensionNumbersResponse(v) => {\n            MessageResponse::AllExtensionNumbersResponse(ExtensionNumberResponse {\n                extension_number: v.extension_number,\n                base_type_name: v.base_type_name,\n            })\n        }\n        v1alpha::server_reflection_response::MessageResponse::ListServicesResponse(v) => {\n            MessageResponse::ListServicesResponse(ListServiceResponse {\n                service: v\n                    .service\n                    .iter()\n                    .map(|s| ServiceResponse { name: s.name.clone() })\n                    .collect(),\n            })\n        }\n        v1alpha::server_reflection_response::MessageResponse::ErrorResponse(v) => {\n            MessageResponse::ErrorResponse(ErrorResponse {\n                error_code: v.error_code,\n                error_message: v.error_message,\n            })\n        }\n    }\n}\n\nfn to_v1alpha_request(request: ServerReflectionRequest) -> v1alpha::ServerReflectionRequest {\n    v1alpha::ServerReflectionRequest {\n        host: request.host,\n        message_request: request.message_request.map(|m| to_v1alpha_msg_request(m)),\n    }\n}\n\nfn to_v1alpha_msg_request(\n    message: MessageRequest,\n) -> v1alpha::server_reflection_request::MessageRequest {\n    match message {\n        MessageRequest::FileByFilename(v) => {\n            v1alpha::server_reflection_request::MessageRequest::FileByFilename(v)\n        }\n        MessageRequest::FileContainingSymbol(v) => {\n            v1alpha::server_reflection_request::MessageRequest::FileContainingSymbol(v)\n        }\n        MessageRequest::FileContainingExtension(ExtensionRequest {\n            extension_number,\n            containing_type,\n        }) => v1alpha::server_reflection_request::MessageRequest::FileContainingExtension(\n            v1alpha::ExtensionRequest { extension_number, containing_type },\n        ),\n        MessageRequest::AllExtensionNumbersOfType(v) => {\n            v1alpha::server_reflection_request::MessageRequest::AllExtensionNumbersOfType(v)\n        }\n        MessageRequest::ListServices(v) => {\n            v1alpha::server_reflection_request::MessageRequest::ListServices(v)\n        }\n    }\n}\n"
  },
  {
    "path": "crates/yaak-grpc/src/codec.rs",
    "content": "use prost_reflect::prost::Message;\nuse prost_reflect::{DynamicMessage, MethodDescriptor};\nuse tonic::Status;\nuse tonic::codec::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder};\n\n#[derive(Clone)]\npub struct DynamicCodec(MethodDescriptor);\n\nimpl DynamicCodec {\n    #[allow(dead_code)]\n    pub fn new(md: MethodDescriptor) -> Self {\n        Self(md)\n    }\n}\n\nimpl Codec for DynamicCodec {\n    type Encode = DynamicMessage;\n    type Decode = DynamicMessage;\n    type Encoder = Self;\n    type Decoder = Self;\n\n    fn encoder(&mut self) -> Self::Encoder {\n        self.clone()\n    }\n\n    fn decoder(&mut self) -> Self::Decoder {\n        self.clone()\n    }\n}\n\nimpl Encoder for DynamicCodec {\n    type Item = DynamicMessage;\n    type Error = Status;\n\n    fn encode(&mut self, item: Self::Item, dst: &mut EncodeBuf<'_>) -> Result<(), Self::Error> {\n        item.encode(dst).expect(\"buffer is too small to decode this message\");\n        Ok(())\n    }\n}\n\nimpl Decoder for DynamicCodec {\n    type Item = DynamicMessage;\n    type Error = Status;\n\n    fn decode(&mut self, src: &mut DecodeBuf<'_>) -> Result<Option<Self::Item>, Self::Error> {\n        let mut msg = DynamicMessage::new(self.0.output());\n        msg.merge(src).map_err(|err| Status::internal(err.to_string()))?;\n        Ok(Some(msg))\n    }\n}\n"
  },
  {
    "path": "crates/yaak-grpc/src/error.rs",
    "content": "use crate::manager::GrpcStreamError;\nuse prost::DecodeError;\nuse serde::{Serialize, Serializer};\nuse serde_json::Error as SerdeJsonError;\nuse std::io;\nuse thiserror::Error;\nuse tonic::Status;\n\n#[derive(Error, Debug)]\npub enum Error {\n    #[error(transparent)]\n    TlsError(#[from] yaak_tls::error::Error),\n\n    #[error(transparent)]\n    TonicError(#[from] Status),\n\n    #[error(\"Prost reflect error: {0:?}\")]\n    ProstReflectError(#[from] prost_reflect::DescriptorError),\n\n    #[error(transparent)]\n    DeserializerError(#[from] SerdeJsonError),\n\n    #[error(transparent)]\n    GrpcStreamError(#[from] GrpcStreamError),\n\n    #[error(transparent)]\n    GrpcDecodeError(#[from] DecodeError),\n\n    #[error(transparent)]\n    GrpcInvalidMetadataKeyError(#[from] tonic::metadata::errors::InvalidMetadataKey),\n\n    #[error(transparent)]\n    GrpcInvalidMetadataValueError(#[from] tonic::metadata::errors::InvalidMetadataValue),\n\n    #[error(transparent)]\n    IOError(#[from] io::Error),\n\n    #[error(\"GRPC error: {0}\")]\n    GenericError(String),\n}\n\nimpl Serialize for Error {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        serializer.serialize_str(self.to_string().as_ref())\n    }\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates/yaak-grpc/src/json_schema.rs",
    "content": "use prost_reflect::{DescriptorPool, FieldDescriptor, MessageDescriptor};\nuse std::collections::{HashMap, HashSet, VecDeque};\n\npub fn message_to_json_schema(_: &DescriptorPool, root_msg: MessageDescriptor) -> JsonSchemaEntry {\n    JsonSchemaGenerator::generate_json_schema(root_msg)\n}\n\nstruct JsonSchemaGenerator {\n    msg_mapping: HashMap<String, JsonSchemaEntry>,\n}\n\nimpl JsonSchemaGenerator {\n    pub fn new() -> Self {\n        JsonSchemaGenerator { msg_mapping: HashMap::new() }\n    }\n\n    pub fn generate_json_schema(msg: MessageDescriptor) -> JsonSchemaEntry {\n        let generator = JsonSchemaGenerator::new();\n        generator.scan_root(msg)\n    }\n\n    fn add_message(&mut self, msg: &MessageDescriptor) {\n        let name = msg.full_name().to_string();\n        if self.msg_mapping.contains_key(&name) {\n            return;\n        }\n        self.msg_mapping.insert(name.clone(), JsonSchemaEntry::object());\n    }\n\n    pub fn scan_root(mut self, root_msg: MessageDescriptor) -> JsonSchemaEntry {\n        self.init_structure(root_msg.clone());\n        self.fill_properties(root_msg.clone());\n\n        let mut root = self.msg_mapping.remove(root_msg.full_name()).unwrap();\n\n        if self.msg_mapping.len() > 0 {\n            root.defs = Some(self.msg_mapping);\n        }\n        root\n    }\n\n    fn fill_properties(&mut self, root_msg: MessageDescriptor) {\n        let root_name = root_msg.full_name().to_string();\n\n        let mut visited = HashSet::new();\n        let mut msg_queue = VecDeque::new();\n        msg_queue.push_back(root_msg);\n\n        while !msg_queue.is_empty() {\n            let msg = msg_queue.pop_front().unwrap();\n            let msg_name = msg.full_name();\n            if visited.contains(msg_name) {\n                continue;\n            }\n\n            visited.insert(msg_name.to_string());\n\n            let entry = self.msg_mapping.get_mut(msg_name).unwrap();\n\n            for field in msg.fields() {\n                let field_name = field.name().to_string();\n\n                if matches!(field.cardinality(), prost_reflect::Cardinality::Required) {\n                    entry.add_required(field_name.clone());\n                }\n\n                if let Some(oneof) = field.containing_oneof() {\n                    for oneof_field in oneof.fields() {\n                        if let Some(fm) = is_message_field(&oneof_field) {\n                            msg_queue.push_back(fm);\n                        }\n                        entry.add_property(\n                            oneof_field.name().to_string(),\n                            field_to_type_or_ref(&root_name, oneof_field),\n                        );\n                    }\n                    continue;\n                }\n\n                let (field_type, nest_msg) = {\n                    if let Some(fm) = is_message_field(&field) {\n                        if field.is_list() {\n                            // repeated message type\n                            (\n                                JsonSchemaEntry::array(field_to_type_or_ref(&root_name, field)),\n                                Some(fm),\n                            )\n                        } else if field.is_map() {\n                            let value_field = fm.get_field_by_name(\"value\").unwrap();\n\n                            if let Some(fm) = is_message_field(&value_field) {\n                                (\n                                    JsonSchemaEntry::map(field_to_type_or_ref(\n                                        &root_name,\n                                        value_field,\n                                    )),\n                                    Some(fm),\n                                )\n                            } else {\n                                (\n                                    JsonSchemaEntry::map(field_to_type_or_ref(\n                                        &root_name,\n                                        value_field,\n                                    )),\n                                    None,\n                                )\n                            }\n                        } else {\n                            (field_to_type_or_ref(&root_name, field), Some(fm))\n                        }\n                    } else {\n                        if field.is_list() {\n                            // repeated scalar type\n                            (JsonSchemaEntry::array(field_to_type_or_ref(&root_name, field)), None)\n                        } else {\n                            (field_to_type_or_ref(&root_name, field), None)\n                        }\n                    }\n                };\n\n                if let Some(fm) = nest_msg {\n                    msg_queue.push_back(fm);\n                }\n\n                entry.add_property(field_name, field_type);\n            }\n        }\n    }\n\n    fn init_structure(&mut self, root_msg: MessageDescriptor) {\n        let mut visited = HashSet::new();\n        let mut msg_queue = VecDeque::new();\n        msg_queue.push_back(root_msg.clone());\n\n        // level traversal, to make sure all message type is defined before used\n        while !msg_queue.is_empty() {\n            let msg = msg_queue.pop_front().unwrap();\n            let name = msg.full_name();\n            if visited.contains(name) {\n                continue;\n            }\n            visited.insert(name.to_string());\n            self.add_message(&msg);\n\n            for child in msg.child_messages() {\n                if child.is_map_entry() {\n                    //  for field with map<key, value> type, there will be a child message type *Entry generated\n                    // just skip it\n                    continue;\n                }\n\n                self.add_message(&child);\n                msg_queue.push_back(child);\n            }\n\n            for field in msg.fields() {\n                if let Some(oneof) = field.containing_oneof() {\n                    for oneof_field in oneof.fields() {\n                        if let Some(fm) = is_message_field(&oneof_field) {\n                            self.add_message(&fm);\n                            msg_queue.push_back(fm);\n                        }\n                    }\n                    continue;\n                }\n                if field.is_map() {\n                    // key is always scalar type, so no need to process\n                    // value can be any type, so need to unpack value type\n                    let map_field_msg = is_message_field(&field).unwrap();\n                    let map_value_field = map_field_msg.get_field_by_name(\"value\").unwrap();\n                    if let Some(value_fm) = is_message_field(&map_value_field) {\n                        self.add_message(&value_fm);\n                        msg_queue.push_back(value_fm);\n                    }\n                    continue;\n                }\n                if let Some(fm) = is_message_field(&field) {\n                    self.add_message(&fm);\n                    msg_queue.push_back(fm);\n                }\n            }\n        }\n    }\n}\n\nfn field_to_type_or_ref(root_name: &str, field: FieldDescriptor) -> JsonSchemaEntry {\n    match field.kind() {\n        prost_reflect::Kind::Bool => JsonSchemaEntry::boolean(),\n        prost_reflect::Kind::Double => JsonSchemaEntry::number(\"double\"),\n        prost_reflect::Kind::Float => JsonSchemaEntry::number(\"float\"),\n        prost_reflect::Kind::Int32 => JsonSchemaEntry::number(\"int32\"),\n        prost_reflect::Kind::Int64 => JsonSchemaEntry::string_with_format(\"int64\"),\n        prost_reflect::Kind::Uint32 => JsonSchemaEntry::number(\"int64\"),\n        prost_reflect::Kind::Uint64 => JsonSchemaEntry::string_with_format(\"uint64\"),\n        prost_reflect::Kind::Sint32 => JsonSchemaEntry::number(\"sint32\"),\n        prost_reflect::Kind::Sint64 => JsonSchemaEntry::string_with_format(\"sint64\"),\n        prost_reflect::Kind::Fixed32 => JsonSchemaEntry::number(\"int64\"),\n        prost_reflect::Kind::Fixed64 => JsonSchemaEntry::string_with_format(\"fixed64\"),\n        prost_reflect::Kind::Sfixed32 => JsonSchemaEntry::number(\"sfixed32\"),\n        prost_reflect::Kind::Sfixed64 => JsonSchemaEntry::string_with_format(\"sfixed64\"),\n        prost_reflect::Kind::String => JsonSchemaEntry::string(),\n        prost_reflect::Kind::Bytes => JsonSchemaEntry::string_with_format(\"byte\"),\n        prost_reflect::Kind::Enum(enums) => {\n            let values = enums.values().map(|v| v.name().to_string()).collect::<Vec<_>>();\n            JsonSchemaEntry::enums(values)\n        }\n        prost_reflect::Kind::Message(fm) => {\n            let field_type_full_name = fm.full_name();\n            match field_type_full_name {\n                // [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/\n                \"google.protobuf.FieldMask\" => JsonSchemaEntry::string(),\n                \"google.protobuf.Timestamp\" => JsonSchemaEntry::string_with_format(\"date-time\"),\n                \"google.protobuf.Duration\" => JsonSchemaEntry::string(),\n                \"google.protobuf.StringValue\" => JsonSchemaEntry::string(),\n                \"google.protobuf.BytesValue\" => JsonSchemaEntry::string_with_format(\"byte\"),\n                \"google.protobuf.Int32Value\" => JsonSchemaEntry::number(\"int32\"),\n                \"google.protobuf.UInt32Value\" => JsonSchemaEntry::string_with_format(\"int64\"),\n                \"google.protobuf.Int64Value\" => JsonSchemaEntry::string_with_format(\"int64\"),\n                \"google.protobuf.UInt64Value\" => JsonSchemaEntry::string_with_format(\"uint64\"),\n                \"google.protobuf.FloatValue\" => JsonSchemaEntry::number(\"float\"),\n                \"google.protobuf.DoubleValue\" => JsonSchemaEntry::number(\"double\"),\n                \"google.protobuf.BoolValue\" => JsonSchemaEntry::boolean(),\n                \"google.protobuf.Empty\" => JsonSchemaEntry::default(),\n                \"google.protobuf.Struct\" => JsonSchemaEntry::object(),\n                \"google.protobuf.ListValue\" => JsonSchemaEntry::array(JsonSchemaEntry::default()),\n                \"google.protobuf.NullValue\" => JsonSchemaEntry::null(),\n                name @ _ if name == root_name => JsonSchemaEntry::root_reference(),\n                _ => JsonSchemaEntry::reference(fm.full_name()),\n            }\n        }\n    }\n}\n\nfn is_message_field(field: &FieldDescriptor) -> Option<MessageDescriptor> {\n    match field.kind() {\n        prost_reflect::Kind::Message(m) => Some(m),\n        _ => None,\n    }\n}\n\n#[derive(Default, serde::Serialize)]\n#[serde(default, rename_all = \"camelCase\")]\npub struct JsonSchemaEntry {\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    title: Option<String>,\n\n    #[serde(rename = \"type\", skip_serializing_if = \"Option::is_none\")]\n    type_: Option<JsonType>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    format: Option<String>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    description: Option<String>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    properties: Option<HashMap<String, JsonSchemaEntry>>,\n\n    #[serde(rename = \"enum\", skip_serializing_if = \"Option::is_none\")]\n    enum_: Option<Vec<String>>,\n\n    // for map type\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    additional_properties: Option<Box<JsonSchemaEntry>>,\n\n    // Set all properties to required\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    required: Option<Vec<String>>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    items: Option<Box<JsonSchemaEntry>>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\", rename = \"$defs\")]\n    defs: Option<HashMap<String, JsonSchemaEntry>>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\", rename = \"$ref\")]\n    ref_: Option<String>,\n}\n\nimpl JsonSchemaEntry {\n    pub fn add_property(&mut self, name: String, entry: JsonSchemaEntry) {\n        if self.properties.is_none() {\n            self.properties = Some(HashMap::new());\n        }\n        self.properties.as_mut().unwrap().insert(name, entry);\n    }\n\n    pub fn add_required(&mut self, name: String) {\n        if self.required.is_none() {\n            self.required = Some(Vec::new());\n        }\n        self.required.as_mut().unwrap().push(name);\n    }\n}\n\nimpl JsonSchemaEntry {\n    pub fn object() -> Self {\n        JsonSchemaEntry { type_: Some(JsonType::Object), ..Default::default() }\n    }\n    pub fn boolean() -> Self {\n        JsonSchemaEntry { type_: Some(JsonType::Boolean), ..Default::default() }\n    }\n    pub fn number<S: Into<String>>(format: S) -> Self {\n        JsonSchemaEntry {\n            type_: Some(JsonType::Number),\n            format: Some(format.into()),\n            ..Default::default()\n        }\n    }\n    pub fn string() -> Self {\n        JsonSchemaEntry { type_: Some(JsonType::String), ..Default::default() }\n    }\n\n    pub fn string_with_format<S: Into<String>>(format: S) -> Self {\n        JsonSchemaEntry {\n            type_: Some(JsonType::String),\n            format: Some(format.into()),\n            ..Default::default()\n        }\n    }\n    pub fn reference<S: AsRef<str>>(ref_: S) -> Self {\n        JsonSchemaEntry { ref_: Some(format!(\"#/$defs/{}\", ref_.as_ref())), ..Default::default() }\n    }\n    pub fn root_reference() -> Self {\n        JsonSchemaEntry { ref_: Some(\"#\".to_string()), ..Default::default() }\n    }\n    pub fn array(item: JsonSchemaEntry) -> Self {\n        JsonSchemaEntry {\n            type_: Some(JsonType::Array),\n            items: Some(Box::new(item)),\n            ..Default::default()\n        }\n    }\n    pub fn enums(enums: Vec<String>) -> Self {\n        JsonSchemaEntry { type_: Some(JsonType::String), enum_: Some(enums), ..Default::default() }\n    }\n\n    pub fn map(value_type: JsonSchemaEntry) -> Self {\n        JsonSchemaEntry {\n            type_: Some(JsonType::Object),\n            additional_properties: Some(Box::new(value_type)),\n            ..Default::default()\n        }\n    }\n\n    pub fn null() -> Self {\n        JsonSchemaEntry { type_: Some(JsonType::Null), ..Default::default() }\n    }\n}\n\nenum JsonType {\n    String,\n    Number,\n    Object,\n    Array,\n    Boolean,\n    Null,\n    _UNKNOWN,\n}\n\nimpl Default for JsonType {\n    fn default() -> Self {\n        JsonType::_UNKNOWN\n    }\n}\n\nimpl serde::Serialize for JsonType {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: serde::Serializer,\n    {\n        match self {\n            JsonType::String => serializer.serialize_str(\"string\"),\n            JsonType::Number => serializer.serialize_str(\"number\"),\n            JsonType::Object => serializer.serialize_str(\"object\"),\n            JsonType::Array => serializer.serialize_str(\"array\"),\n            JsonType::Boolean => serializer.serialize_str(\"boolean\"),\n            JsonType::Null => serializer.serialize_str(\"null\"),\n            JsonType::_UNKNOWN => serializer.serialize_str(\"unknown\"),\n        }\n    }\n}\n"
  },
  {
    "path": "crates/yaak-grpc/src/lib.rs",
    "content": "use prost_reflect::{DynamicMessage, MethodDescriptor, SerializeOptions};\nuse serde::{Deserialize, Serialize};\nuse serde_json::Deserializer;\n\nmod any;\nmod client;\nmod codec;\npub mod error;\nmod json_schema;\npub mod manager;\nmod reflection;\nmod transport;\n\npub use tonic::Code;\npub use tonic::metadata::*;\n\npub fn serialize_options() -> SerializeOptions {\n    SerializeOptions::new().skip_default_fields(false)\n}\n\n#[derive(Serialize, Deserialize, Debug, Default)]\n#[serde(default, rename_all = \"camelCase\")]\npub struct ServiceDefinition {\n    pub name: String,\n    pub methods: Vec<MethodDefinition>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Default)]\n#[serde(default, rename_all = \"camelCase\")]\npub struct MethodDefinition {\n    pub name: String,\n    pub schema: String,\n    pub client_streaming: bool,\n    pub server_streaming: bool,\n}\n\nstatic SERIALIZE_OPTIONS: &'static SerializeOptions =\n    &SerializeOptions::new().skip_default_fields(false).stringify_64_bit_integers(false);\n\npub fn serialize_message(msg: &DynamicMessage) -> Result<String, String> {\n    let mut buf = Vec::new();\n    let mut se = serde_json::Serializer::pretty(&mut buf);\n    msg.serialize_with_options(&mut se, SERIALIZE_OPTIONS).map_err(|e| e.to_string())?;\n    let s = String::from_utf8(buf).expect(\"serde_json to emit valid utf8\");\n    Ok(s)\n}\n\npub fn deserialize_message(msg: &str, method: MethodDescriptor) -> Result<DynamicMessage, String> {\n    let mut deserializer = Deserializer::from_str(&msg);\n    let req_message = DynamicMessage::deserialize(method.input(), &mut deserializer)\n        .map_err(|e| e.to_string())?;\n    deserializer.end().map_err(|e| e.to_string())?;\n    Ok(req_message)\n}\n"
  },
  {
    "path": "crates/yaak-grpc/src/manager.rs",
    "content": "use crate::codec::DynamicCodec;\nuse crate::error::Error::GenericError;\nuse crate::error::Result;\nuse crate::reflection::{\n    fill_pool_from_files, fill_pool_from_reflection, method_desc_to_path, reflect_types_for_message,\n};\nuse crate::transport::get_transport;\nuse crate::{MethodDefinition, ServiceDefinition, json_schema};\nuse hyper_rustls::HttpsConnector;\nuse hyper_util::client::legacy::Client;\nuse hyper_util::client::legacy::connect::HttpConnector;\nuse log::{info, warn};\npub use prost_reflect::DynamicMessage;\nuse prost_reflect::{DescriptorPool, MethodDescriptor, ServiceDescriptor};\nuse serde_json::Deserializer;\nuse std::collections::BTreeMap;\nuse std::error::Error;\nuse std::fmt;\nuse std::fmt::Display;\nuse std::path::PathBuf;\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\nuse tokio_stream::StreamExt;\nuse tokio_stream::wrappers::ReceiverStream;\nuse tonic::body::BoxBody;\nuse tonic::metadata::{MetadataKey, MetadataValue};\nuse tonic::transport::Uri;\nuse tonic::{IntoRequest, IntoStreamingRequest, Request, Response, Status, Streaming};\nuse yaak_tls::ClientCertificateConfig;\n\n#[derive(Clone)]\npub struct GrpcConnection {\n    pool: Arc<RwLock<DescriptorPool>>,\n    conn: Client<HttpsConnector<HttpConnector>, BoxBody>,\n    pub uri: Uri,\n    use_reflection: bool,\n}\n\n#[derive(Default, Debug)]\npub struct GrpcStreamError {\n    pub message: String,\n    pub status: Option<Status>,\n}\n\nimpl Error for GrpcStreamError {}\n\nimpl Display for GrpcStreamError {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match &self.status {\n            Some(status) => write!(f, \"[{}] {}\", status, self.message),\n            None => write!(f, \"{}\", self.message),\n        }\n    }\n}\n\nimpl From<String> for GrpcStreamError {\n    fn from(value: String) -> Self {\n        GrpcStreamError { message: value.to_string(), status: None }\n    }\n}\n\nimpl From<Status> for GrpcStreamError {\n    fn from(s: Status) -> Self {\n        GrpcStreamError { message: s.message().to_string(), status: Some(s) }\n    }\n}\n\nimpl GrpcConnection {\n    pub async fn method(&self, service: &str, method: &str) -> Result<MethodDescriptor> {\n        let service = self.service(service).await?;\n        let method = service\n            .methods()\n            .find(|m| m.name() == method)\n            .ok_or(GenericError(\"Failed to find method\".to_string()))?;\n        Ok(method)\n    }\n\n    async fn service(&self, service: &str) -> Result<ServiceDescriptor> {\n        let pool = self.pool.read().await;\n        let service = pool\n            .get_service_by_name(service)\n            .ok_or(GenericError(\"Failed to find service\".to_string()))?;\n        Ok(service)\n    }\n\n    pub async fn unary(\n        &self,\n        service: &str,\n        method: &str,\n        message: &str,\n        metadata: &BTreeMap<String, String>,\n        client_cert: Option<ClientCertificateConfig>,\n    ) -> Result<Response<DynamicMessage>> {\n        if self.use_reflection {\n            reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata, client_cert)\n                .await?;\n        }\n        let method = &self.method(&service, &method).await?;\n        let input_message = method.input();\n\n        let mut deserializer = Deserializer::from_str(message);\n        let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;\n        deserializer.end()?;\n\n        let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());\n\n        let mut req = req_message.into_request();\n        decorate_req(metadata, &mut req)?;\n\n        let path = method_desc_to_path(method);\n        let codec = DynamicCodec::new(method.clone());\n        client.ready().await.map_err(|e| GenericError(format!(\"Failed to connect: {}\", e)))?;\n\n        Ok(client.unary(req, path, codec).await?)\n    }\n\n    pub async fn streaming<F>(\n        &self,\n        service: &str,\n        method: &str,\n        stream: ReceiverStream<String>,\n        metadata: &BTreeMap<String, String>,\n        client_cert: Option<ClientCertificateConfig>,\n        on_message: F,\n    ) -> Result<Response<Streaming<DynamicMessage>>>\n    where\n        F: Fn(std::result::Result<String, String>) + Send + Sync + Clone + 'static,\n    {\n        let method = &self.method(&service, &method).await?;\n        let mapped_stream = {\n            let input_message = method.input();\n            let pool = self.pool.clone();\n            let uri = self.uri.clone();\n            let md = metadata.clone();\n            let use_reflection = self.use_reflection.clone();\n            let client_cert = client_cert.clone();\n            stream\n                .then(move |json| {\n                    let pool = pool.clone();\n                    let uri = uri.clone();\n                    let input_message = input_message.clone();\n                    let md = md.clone();\n                    let use_reflection = use_reflection.clone();\n                    let client_cert = client_cert.clone();\n                    let on_message = on_message.clone();\n                    let json_clone = json.clone();\n                    async move {\n                        if use_reflection {\n                            if let Err(e) =\n                                reflect_types_for_message(pool, &uri, &json, &md, client_cert).await\n                            {\n                                warn!(\"Failed to resolve Any types: {e}\");\n                            }\n                        }\n                        let mut de = Deserializer::from_str(&json);\n                        match DynamicMessage::deserialize(input_message, &mut de) {\n                            Ok(m) => {\n                                on_message(Ok(json_clone));\n                                Some(m)\n                            }\n                            Err(e) => {\n                                warn!(\"Failed to deserialize message: {e}\");\n                                on_message(Err(e.to_string()));\n                                None\n                            }\n                        }\n                    }\n                })\n                .filter_map(|x| x)\n        };\n\n        let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());\n        let path = method_desc_to_path(method);\n        let codec = DynamicCodec::new(method.clone());\n\n        let mut req = mapped_stream.into_streaming_request();\n        decorate_req(metadata, &mut req)?;\n\n        client.ready().await.map_err(|e| GenericError(format!(\"Failed to connect: {}\", e)))?;\n        Ok(client.streaming(req, path, codec).await?)\n    }\n\n    pub async fn client_streaming<F>(\n        &self,\n        service: &str,\n        method: &str,\n        stream: ReceiverStream<String>,\n        metadata: &BTreeMap<String, String>,\n        client_cert: Option<ClientCertificateConfig>,\n        on_message: F,\n    ) -> Result<Response<DynamicMessage>>\n    where\n        F: Fn(std::result::Result<String, String>) + Send + Sync + Clone + 'static,\n    {\n        let method = &self.method(&service, &method).await?;\n        let mapped_stream = {\n            let input_message = method.input();\n            let pool = self.pool.clone();\n            let uri = self.uri.clone();\n            let md = metadata.clone();\n            let use_reflection = self.use_reflection.clone();\n            let client_cert = client_cert.clone();\n            stream\n                .then(move |json| {\n                    let pool = pool.clone();\n                    let uri = uri.clone();\n                    let input_message = input_message.clone();\n                    let md = md.clone();\n                    let use_reflection = use_reflection.clone();\n                    let client_cert = client_cert.clone();\n                    let on_message = on_message.clone();\n                    let json_clone = json.clone();\n                    async move {\n                        if use_reflection {\n                            if let Err(e) =\n                                reflect_types_for_message(pool, &uri, &json, &md, client_cert).await\n                            {\n                                warn!(\"Failed to resolve Any types: {e}\");\n                            }\n                        }\n                        let mut de = Deserializer::from_str(&json);\n                        match DynamicMessage::deserialize(input_message, &mut de) {\n                            Ok(m) => {\n                                on_message(Ok(json_clone));\n                                Some(m)\n                            }\n                            Err(e) => {\n                                warn!(\"Failed to deserialize message: {e}\");\n                                on_message(Err(e.to_string()));\n                                None\n                            }\n                        }\n                    }\n                })\n                .filter_map(|x| x)\n        };\n\n        let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());\n        let path = method_desc_to_path(method);\n        let codec = DynamicCodec::new(method.clone());\n\n        let mut req = mapped_stream.into_streaming_request();\n        decorate_req(metadata, &mut req)?;\n\n        client.ready().await.map_err(|e| GenericError(format!(\"Failed to connect: {}\", e)))?;\n        Ok(client\n            .client_streaming(req, path, codec)\n            .await\n            .map_err(|e| GrpcStreamError { message: e.message().to_string(), status: Some(e) })?)\n    }\n\n    pub async fn server_streaming(\n        &self,\n        service: &str,\n        method: &str,\n        message: &str,\n        metadata: &BTreeMap<String, String>,\n    ) -> Result<Response<Streaming<DynamicMessage>>> {\n        let method = &self.method(&service, &method).await?;\n        let input_message = method.input();\n\n        let mut deserializer = Deserializer::from_str(message);\n        let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;\n        deserializer.end()?;\n\n        let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());\n\n        let mut req = req_message.into_request();\n        decorate_req(metadata, &mut req)?;\n\n        let path = method_desc_to_path(method);\n        let codec = DynamicCodec::new(method.clone());\n        client.ready().await.map_err(|e| GenericError(format!(\"Failed to connect: {}\", e)))?;\n        Ok(client.server_streaming(req, path, codec).await?)\n    }\n}\n\n/// Configuration for GrpcHandle to compile proto files\n#[derive(Clone)]\npub struct GrpcConfig {\n    /// Path to the protoc include directory (vendored/protoc/include)\n    pub protoc_include_dir: PathBuf,\n    /// Path to the yaakprotoc sidecar binary\n    pub protoc_bin_path: PathBuf,\n}\n\npub struct GrpcHandle {\n    config: GrpcConfig,\n    pools: BTreeMap<String, DescriptorPool>,\n}\n\nimpl GrpcHandle {\n    pub fn new(config: GrpcConfig) -> Self {\n        let pools = BTreeMap::new();\n        Self { pools, config }\n    }\n}\n\nimpl GrpcHandle {\n    /// Remove cached descriptor pool for the given key, if present.\n    pub fn invalidate_pool(&mut self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) {\n        let key = make_pool_key(id, uri, proto_files);\n        self.pools.remove(&key);\n    }\n\n    pub async fn reflect(\n        &mut self,\n        id: &str,\n        uri: &str,\n        proto_files: &Vec<PathBuf>,\n        metadata: &BTreeMap<String, String>,\n        validate_certificates: bool,\n        client_cert: Option<ClientCertificateConfig>,\n    ) -> Result<bool> {\n        let server_reflection = proto_files.is_empty();\n        let key = make_pool_key(id, uri, proto_files);\n\n        // If we already have a pool for this key, reuse it and avoid re-reflection\n        if self.pools.contains_key(&key) {\n            return Ok(server_reflection);\n        }\n\n        let pool = if server_reflection {\n            let full_uri = uri_from_str(uri)?;\n            fill_pool_from_reflection(&full_uri, metadata, validate_certificates, client_cert).await\n        } else {\n            fill_pool_from_files(&self.config, proto_files).await\n        }?;\n\n        self.pools.insert(key, pool.clone());\n        Ok(server_reflection)\n    }\n\n    pub async fn services(\n        &mut self,\n        id: &str,\n        uri: &str,\n        proto_files: &Vec<PathBuf>,\n        metadata: &BTreeMap<String, String>,\n        validate_certificates: bool,\n        client_cert: Option<ClientCertificateConfig>,\n    ) -> Result<Vec<ServiceDefinition>> {\n        // Ensure we have a pool; reflect only if missing\n        if self.get_pool(id, uri, proto_files).is_none() {\n            info!(\"Reflecting gRPC services for {} at {}\", id, uri);\n            self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert)\n                .await?;\n        }\n\n        let pool = self\n            .get_pool(id, uri, proto_files)\n            .ok_or(GenericError(\"Failed to get pool\".to_string()))?;\n        Ok(self.services_from_pool(&pool))\n    }\n\n    fn services_from_pool(&self, pool: &DescriptorPool) -> Vec<ServiceDefinition> {\n        pool.services()\n            .map(|s| {\n                let mut def =\n                    ServiceDefinition { name: s.full_name().to_string(), methods: vec![] };\n                for method in s.methods() {\n                    let input_message = method.input();\n                    def.methods.push(MethodDefinition {\n                        name: method.name().to_string(),\n                        server_streaming: method.is_server_streaming(),\n                        client_streaming: method.is_client_streaming(),\n                        schema: serde_json::to_string_pretty(&json_schema::message_to_json_schema(\n                            &pool,\n                            input_message,\n                        ))\n                        .expect(\"Failed to serialize JSON schema\"),\n                    })\n                }\n                def\n            })\n            .collect::<Vec<_>>()\n    }\n\n    pub async fn connect(\n        &mut self,\n        id: &str,\n        uri: &str,\n        proto_files: &Vec<PathBuf>,\n        metadata: &BTreeMap<String, String>,\n        validate_certificates: bool,\n        client_cert: Option<ClientCertificateConfig>,\n    ) -> Result<GrpcConnection> {\n        let use_reflection = proto_files.is_empty();\n        if self.get_pool(id, uri, proto_files).is_none() {\n            self.reflect(\n                id,\n                uri,\n                proto_files,\n                metadata,\n                validate_certificates,\n                client_cert.clone(),\n            )\n            .await?;\n        }\n        let pool = self\n            .get_pool(id, uri, proto_files)\n            .ok_or(GenericError(\"Failed to get pool\".to_string()))?\n            .clone();\n        let uri = uri_from_str(uri)?;\n        let conn = get_transport(validate_certificates, client_cert.clone())?;\n        Ok(GrpcConnection { pool: Arc::new(RwLock::new(pool)), use_reflection, conn, uri })\n    }\n\n    fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> Option<&DescriptorPool> {\n        self.pools.get(make_pool_key(id, uri, proto_files).as_str())\n    }\n}\n\npub(crate) fn decorate_req<T>(\n    metadata: &BTreeMap<String, String>,\n    req: &mut Request<T>,\n) -> Result<()> {\n    for (k, v) in metadata {\n        req.metadata_mut()\n            .insert(MetadataKey::from_str(k.as_str())?, MetadataValue::from_str(v.as_str())?);\n    }\n    Ok(())\n}\n\nfn uri_from_str(uri_str: &str) -> Result<Uri> {\n    match Uri::from_str(uri_str) {\n        Ok(uri) => Ok(uri),\n        Err(err) => {\n            // Uri::from_str basically only returns \"invalid format\" so we add more context here\n            Err(GenericError(format!(\"Failed to parse URL, {}\", err.to_string())))\n        }\n    }\n}\n\nfn make_pool_key(id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> String {\n    let pool_key = format!(\n        \"{}::{}::{}\",\n        id,\n        uri,\n        proto_files\n            .iter()\n            .map(|p| p.to_string_lossy().to_string())\n            .collect::<Vec<String>>()\n            .join(\":\")\n    );\n\n    format!(\"{:x}\", md5::compute(pool_key))\n}\n"
  },
  {
    "path": "crates/yaak-grpc/src/reflection.rs",
    "content": "use crate::any::collect_any_types;\nuse crate::client::AutoReflectionClient;\nuse crate::error::Error::GenericError;\nuse crate::error::Result;\nuse crate::manager::GrpcConfig;\nuse anyhow::anyhow;\nuse async_recursion::async_recursion;\nuse log::{debug, info, warn};\nuse prost::Message;\nuse prost_reflect::{DescriptorPool, MethodDescriptor};\nuse prost_types::{FileDescriptorProto, FileDescriptorSet};\nuse std::collections::{BTreeMap, HashSet};\nuse std::env::temp_dir;\nuse std::ops::Deref;\nuse std::path::{Path, PathBuf};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tokio::fs;\nuse tokio::sync::RwLock;\nuse tonic::codegen::http::uri::PathAndQuery;\nuse tonic::transport::Uri;\nuse tonic_reflection::pb::v1::server_reflection_request::MessageRequest;\nuse tonic_reflection::pb::v1::server_reflection_response::MessageResponse;\nuse yaak_common::command::new_xplatform_command;\nuse yaak_tls::ClientCertificateConfig;\n\npub async fn fill_pool_from_files(\n    config: &GrpcConfig,\n    paths: &Vec<PathBuf>,\n) -> Result<DescriptorPool> {\n    let mut pool = DescriptorPool::new();\n    let random_file_name = format!(\"{}.desc\", uuid::Uuid::new_v4());\n    let desc_path = temp_dir().join(random_file_name);\n\n    // HACK: Remove UNC prefix for Windows paths\n    let global_import_dir =\n        dunce::simplified(config.protoc_include_dir.as_path()).to_string_lossy().to_string();\n    let desc_path = dunce::simplified(desc_path.as_path());\n\n    let mut args = vec![\n        \"--include_imports\".to_string(),\n        \"--include_source_info\".to_string(),\n        \"-I\".to_string(),\n        global_import_dir,\n        \"-o\".to_string(),\n        desc_path.to_string_lossy().to_string(),\n    ];\n\n    let mut include_dirs = HashSet::new();\n    let mut include_protos = HashSet::new();\n\n    for p in paths {\n        if !p.exists() {\n            continue;\n        }\n\n        // Dirs are added as includes\n        if p.is_dir() {\n            include_dirs.insert(p.to_string_lossy().to_string());\n            continue;\n        }\n\n        let parent = p.as_path().parent();\n        if let Some(parent_path) = parent {\n            match find_parent_proto_dir(parent_path) {\n                None => {\n                    // Add parent/grandparent as fallback\n                    include_dirs.insert(parent_path.to_string_lossy().to_string());\n                    if let Some(grandparent_path) = parent_path.parent() {\n                        include_dirs.insert(grandparent_path.to_string_lossy().to_string());\n                    }\n                }\n                Some(p) => {\n                    include_dirs.insert(p.to_string_lossy().to_string());\n                }\n            };\n        } else {\n            debug!(\"ignoring {:?} since it does not exist.\", parent)\n        }\n\n        include_protos.insert(p.to_string_lossy().to_string());\n    }\n\n    for d in include_dirs.clone() {\n        args.push(\"-I\".to_string());\n        args.push(d);\n    }\n    for p in include_protos.clone() {\n        args.push(p);\n    }\n\n    info!(\"Invoking protoc with {}\", args.join(\" \"));\n\n    let mut cmd = new_xplatform_command(&config.protoc_bin_path);\n    cmd.args(&args);\n\n    let out =\n        cmd.output().await.map_err(|e| GenericError(format!(\"Failed to run protoc: {}\", e)))?;\n\n    if !out.status.success() {\n        return Err(GenericError(format!(\n            \"protoc failed with status {}: {}\",\n            out.status.code().unwrap_or(-1),\n            String::from_utf8_lossy(out.stderr.as_slice())\n        )));\n    }\n\n    let bytes = fs::read(desc_path).await?;\n    let fdp = FileDescriptorSet::decode(bytes.deref())?;\n    pool.add_file_descriptor_set(fdp)?;\n\n    fs::remove_file(desc_path).await?;\n\n    Ok(pool)\n}\n\npub async fn fill_pool_from_reflection(\n    uri: &Uri,\n    metadata: &BTreeMap<String, String>,\n    validate_certificates: bool,\n    client_cert: Option<ClientCertificateConfig>,\n) -> Result<DescriptorPool> {\n    let mut pool = DescriptorPool::new();\n    let mut client = AutoReflectionClient::new(uri, validate_certificates, client_cert)?;\n\n    for service in list_services(&mut client, metadata).await? {\n        if service == \"grpc.reflection.v1alpha.ServerReflection\" {\n            continue;\n        }\n        if service == \"grpc.reflection.v1.ServerReflection\" {\n            continue;\n        }\n        debug!(\"Fetching descriptors for {}\", service);\n        file_descriptor_set_from_service_name(&service, &mut pool, &mut client, metadata).await;\n    }\n\n    Ok(pool)\n}\n\nasync fn list_services(\n    client: &mut AutoReflectionClient,\n    metadata: &BTreeMap<String, String>,\n) -> Result<Vec<String>> {\n    let response =\n        client.send_reflection_request(MessageRequest::ListServices(\"\".into()), metadata).await?;\n\n    let list_services_response = match response {\n        MessageResponse::ListServicesResponse(resp) => resp,\n        _ => panic!(\"Expected a ListServicesResponse variant\"),\n    };\n\n    Ok(list_services_response.service.iter().map(|s| s.name.clone()).collect::<Vec<_>>())\n}\n\nasync fn file_descriptor_set_from_service_name(\n    service_name: &str,\n    pool: &mut DescriptorPool,\n    client: &mut AutoReflectionClient,\n    metadata: &BTreeMap<String, String>,\n) {\n    let response = match client\n        .send_reflection_request(\n            MessageRequest::FileContainingSymbol(service_name.into()),\n            metadata,\n        )\n        .await\n    {\n        Ok(resp) => resp,\n        Err(e) => {\n            warn!(\"Error fetching file descriptor for service {}: {:?}\", service_name, e);\n            return;\n        }\n    };\n\n    let file_descriptor_response = match response {\n        MessageResponse::FileDescriptorResponse(resp) => resp,\n        _ => panic!(\"Expected a FileDescriptorResponse variant\"),\n    };\n\n    add_file_descriptors_to_pool(\n        file_descriptor_response.file_descriptor_proto,\n        pool,\n        client,\n        metadata,\n    )\n    .await;\n}\n\npub(crate) async fn reflect_types_for_message(\n    pool: Arc<RwLock<DescriptorPool>>,\n    uri: &Uri,\n    json: &str,\n    metadata: &BTreeMap<String, String>,\n    client_cert: Option<ClientCertificateConfig>,\n) -> Result<()> {\n    // 1. Collect all Any types in the JSON\n    let mut extra_types = Vec::new();\n    collect_any_types(json, &mut extra_types);\n\n    if extra_types.is_empty() {\n        return Ok(()); // nothing to do\n    }\n\n    let mut client = AutoReflectionClient::new(uri, false, client_cert)?;\n    for extra_type in extra_types {\n        {\n            let guard = pool.read().await;\n            if guard.get_message_by_name(&extra_type).is_some() {\n                continue;\n            }\n        }\n        info!(\"Adding file descriptor for {:?} from reflection\", extra_type);\n        let req = MessageRequest::FileContainingSymbol(extra_type.clone().into());\n        let resp = match client.send_reflection_request(req, metadata).await {\n            Ok(r) => r,\n            Err(e) => {\n                return Err(GenericError(format!(\n                    \"Error sending reflection request for @type \\\"{extra_type}\\\": {e:?}\",\n                )));\n            }\n        };\n        let files = match resp {\n            MessageResponse::FileDescriptorResponse(resp) => resp.file_descriptor_proto,\n            _ => panic!(\"Expected a FileDescriptorResponse variant\"),\n        };\n\n        {\n            let mut guard = pool.write().await;\n            add_file_descriptors_to_pool(files, &mut *guard, &mut client, metadata).await;\n        }\n    }\n\n    Ok(())\n}\n\n#[async_recursion]\npub(crate) async fn add_file_descriptors_to_pool(\n    fds: Vec<Vec<u8>>,\n    pool: &mut DescriptorPool,\n    client: &mut AutoReflectionClient,\n    metadata: &BTreeMap<String, String>,\n) {\n    let mut topo_sort = topology::SimpleTopoSort::new();\n    let mut fd_mapping = std::collections::HashMap::with_capacity(fds.len());\n\n    for fd in fds {\n        let fdp = FileDescriptorProto::decode(fd.deref()).unwrap();\n\n        topo_sort.insert(fdp.name().to_string(), fdp.dependency.clone());\n        fd_mapping.insert(fdp.name().to_string(), fdp);\n    }\n\n    for node in topo_sort {\n        match node {\n            Ok(node) => {\n                if let Some(fdp) = fd_mapping.remove(&node) {\n                    pool.add_file_descriptor_proto(fdp).expect(\"add file descriptor proto\");\n                } else {\n                    file_descriptor_set_by_filename(node.as_str(), pool, client, metadata).await;\n                }\n            }\n            Err(_) => panic!(\"proto file got cycle!\"),\n        }\n    }\n}\n\nasync fn file_descriptor_set_by_filename(\n    filename: &str,\n    pool: &mut DescriptorPool,\n    client: &mut AutoReflectionClient,\n    metadata: &BTreeMap<String, String>,\n) {\n    // We already fetched this file\n    if let Some(_) = pool.get_file_by_name(filename) {\n        return;\n    }\n\n    let msg = MessageRequest::FileByFilename(filename.into());\n    let response = client.send_reflection_request(msg, metadata).await;\n    let file_descriptor_response = match response {\n        Ok(MessageResponse::FileDescriptorResponse(resp)) => resp,\n        Ok(_) => {\n            panic!(\"Expected a FileDescriptorResponse variant\")\n        }\n        Err(e) => {\n            warn!(\"Error fetching file descriptor for {}: {:?}\", filename, e);\n            return;\n        }\n    };\n\n    add_file_descriptors_to_pool(\n        file_descriptor_response.file_descriptor_proto,\n        pool,\n        client,\n        metadata,\n    )\n    .await;\n}\n\npub fn method_desc_to_path(md: &MethodDescriptor) -> PathAndQuery {\n    let full_name = md.full_name();\n    let (namespace, method_name) = full_name\n        .rsplit_once('.')\n        .ok_or_else(|| anyhow!(\"invalid method path\"))\n        .expect(\"invalid method path\");\n    PathAndQuery::from_str(&format!(\"/{}/{}\", namespace, method_name)).expect(\"invalid method path\")\n}\n\nmod topology {\n    use std::collections::{HashMap, HashSet};\n\n    pub struct SimpleTopoSort<T> {\n        out_graph: HashMap<T, HashSet<T>>,\n        in_graph: HashMap<T, HashSet<T>>,\n    }\n\n    impl<T> SimpleTopoSort<T>\n    where\n        T: Eq + std::hash::Hash + Clone,\n    {\n        pub fn new() -> Self {\n            SimpleTopoSort { out_graph: HashMap::new(), in_graph: HashMap::new() }\n        }\n\n        pub fn insert<I: IntoIterator<Item = T>>(&mut self, node: T, deps: I) {\n            self.out_graph.entry(node.clone()).or_insert(HashSet::new());\n            for dep in deps {\n                self.out_graph.entry(node.clone()).or_insert(HashSet::new()).insert(dep.clone());\n                self.in_graph.entry(dep.clone()).or_insert(HashSet::new()).insert(node.clone());\n            }\n        }\n    }\n\n    impl<T> IntoIterator for SimpleTopoSort<T>\n    where\n        T: Eq + std::hash::Hash + Clone,\n    {\n        type Item = <SimpleTopoSortIter<T> as Iterator>::Item;\n        type IntoIter = SimpleTopoSortIter<T>;\n\n        fn into_iter(self) -> Self::IntoIter {\n            SimpleTopoSortIter::new(self)\n        }\n    }\n\n    pub struct SimpleTopoSortIter<T> {\n        data: SimpleTopoSort<T>,\n        zero_indegree: Vec<T>,\n    }\n\n    impl<T> SimpleTopoSortIter<T>\n    where\n        T: Eq + std::hash::Hash + Clone,\n    {\n        pub fn new(data: SimpleTopoSort<T>) -> Self {\n            let mut zero_indegree = Vec::new();\n            for (node, _) in data.in_graph.iter() {\n                if !data.out_graph.contains_key(node) {\n                    zero_indegree.push(node.clone());\n                }\n            }\n            for (node, deps) in data.out_graph.iter() {\n                if deps.is_empty() {\n                    zero_indegree.push(node.clone());\n                }\n            }\n\n            SimpleTopoSortIter { data, zero_indegree }\n        }\n    }\n\n    impl<T> Iterator for SimpleTopoSortIter<T>\n    where\n        T: Eq + std::hash::Hash + Clone,\n    {\n        type Item = Result<T, &'static str>;\n\n        fn next(&mut self) -> Option<Self::Item> {\n            if self.zero_indegree.is_empty() {\n                if self.data.out_graph.is_empty() {\n                    return None;\n                }\n                return Some(Err(\"Cycle detected\"));\n            }\n\n            let node = self.zero_indegree.pop().unwrap();\n            if let Some(parents) = self.data.in_graph.get(&node) {\n                for parent in parents.iter() {\n                    let deps = self.data.out_graph.get_mut(parent).unwrap();\n                    deps.remove(&node);\n                    if deps.is_empty() {\n                        self.zero_indegree.push(parent.clone());\n                    }\n                }\n            }\n            self.data.out_graph.remove(&node);\n\n            Some(Ok(node))\n        }\n    }\n\n    #[test]\n    fn test_sort() {\n        {\n            let mut topo_sort = SimpleTopoSort::new();\n            topo_sort.insert(\"a\", []);\n\n            for node in topo_sort {\n                match node {\n                    Ok(n) => assert_eq!(n, \"a\"),\n                    Err(e) => panic!(\"err {}\", e),\n                }\n            }\n        }\n\n        {\n            let mut topo_sort = SimpleTopoSort::new();\n            topo_sort.insert(\"a\", [\"b\"]);\n            topo_sort.insert(\"b\", []);\n\n            let mut iter = topo_sort.into_iter();\n            match iter.next() {\n                Some(Ok(n)) => assert_eq!(n, \"b\"),\n                _ => panic!(\"err\"),\n            }\n            match iter.next() {\n                Some(Ok(n)) => assert_eq!(n, \"a\"),\n                _ => panic!(\"err\"),\n            }\n            assert_eq!(iter.next(), None);\n        }\n    }\n}\n\nfn find_parent_proto_dir(start_path: impl AsRef<Path>) -> Option<PathBuf> {\n    let mut dir = start_path.as_ref().canonicalize().ok()?;\n\n    loop {\n        if let Some(name) = dir.file_name().and_then(|n| n.to_str()) {\n            if name == \"proto\" {\n                return Some(dir);\n            }\n        }\n\n        let parent = dir.parent()?;\n        if parent == dir {\n            return None; // Reached root\n        }\n\n        dir = parent.to_path_buf();\n    }\n}\n"
  },
  {
    "path": "crates/yaak-grpc/src/transport.rs",
    "content": "use crate::error::Result;\nuse hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};\nuse hyper_util::client::legacy::Client;\nuse hyper_util::client::legacy::connect::HttpConnector;\nuse hyper_util::rt::TokioExecutor;\nuse log::info;\nuse tonic::body::BoxBody;\nuse yaak_tls::{ClientCertificateConfig, get_tls_config};\n\n// I think ALPN breaks this because we're specifying http2_only\nconst WITH_ALPN: bool = false;\n\npub(crate) fn get_transport(\n    validate_certificates: bool,\n    client_cert: Option<ClientCertificateConfig>,\n) -> Result<Client<HttpsConnector<HttpConnector>, BoxBody>> {\n    let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?;\n\n    let mut http = HttpConnector::new();\n    http.enforce_http(false);\n\n    let connector = HttpsConnectorBuilder::new()\n        .with_tls_config(tls_config)\n        .https_or_http()\n        .enable_http2()\n        .build();\n\n    let client = Client::builder(TokioExecutor::new())\n        .pool_max_idle_per_host(0)\n        .http2_only(true)\n        .build(connector);\n\n    info!(\n        \"Created gRPC client validate_certs={} client_cert={}\",\n        validate_certificates,\n        client_cert.is_some()\n    );\n\n    Ok(client)\n}\n"
  },
  {
    "path": "crates/yaak-http/Cargo.toml",
    "content": "[package]\nname = \"yaak-http\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nasync-compression = { version = \"0.4\", features = [\"tokio\", \"gzip\", \"deflate\", \"brotli\", \"zstd\"] }\nasync-trait = \"0.1\"\nbrotli = \"7\"\nbytes = \"1.11.1\"\ncookie = \"0.18.1\"\nflate2 = \"1\"\nfutures-util = \"0.3\"\nhttp-body = \"1\"\nurl = \"2\"\nzstd = \"0.13\"\nhyper-util = { version = \"0.1.17\", default-features = false, features = [\"client-legacy\"] }\nlog = { workspace = true }\nmime_guess = \"2.0.5\"\nregex = \"1.11.1\"\nreqwest = { workspace = true, features = [\n  \"rustls-tls-manual-roots-no-provider\",\n  \"socks\",\n  \"http2\",\n  \"stream\",\n] }\nserde = { workspace = true, features = [\"derive\"] }\nserde_json = { workspace = true }\nthiserror = { workspace = true }\ntokio = { workspace = true, features = [\"macros\", \"rt\", \"fs\", \"io-util\"] }\ntokio-util = { version = \"0.7\", features = [\"codec\", \"io\", \"io-util\"] }\ntower-service = \"0.3.3\"\nurlencoding = \"2.1.3\"\nyaak-common = { workspace = true }\nyaak-models = { workspace = true }\nyaak-templates = { workspace = true }\nyaak-tls = { workspace = true }\n"
  },
  {
    "path": "crates/yaak-http/src/chained_reader.rs",
    "content": "use std::io;\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\nuse tokio::io::{AsyncRead, ReadBuf};\n\n/// A stream that chains multiple AsyncRead sources together\npub(crate) struct ChainedReader {\n    readers: Vec<ReaderType>,\n    current_index: usize,\n    current_reader: Option<Box<dyn AsyncRead + Send + Unpin + 'static>>,\n}\n\n#[derive(Clone)]\npub(crate) enum ReaderType {\n    Bytes(Vec<u8>),\n    FilePath(String),\n}\n\nimpl ChainedReader {\n    pub(crate) fn new(readers: Vec<ReaderType>) -> Self {\n        Self { readers, current_index: 0, current_reader: None }\n    }\n}\n\nimpl AsyncRead for ChainedReader {\n    fn poll_read(\n        mut self: Pin<&mut Self>,\n        cx: &mut Context<'_>,\n        buf: &mut ReadBuf<'_>,\n    ) -> Poll<io::Result<()>> {\n        loop {\n            // Try to read from current reader if we have one\n            if let Some(ref mut reader) = self.current_reader {\n                let before_len = buf.filled().len();\n                return match Pin::new(reader).poll_read(cx, buf) {\n                    Poll::Ready(Ok(())) => {\n                        if buf.filled().len() == before_len && buf.remaining() > 0 {\n                            // Current reader is exhausted, move to next\n                            self.current_reader = None;\n                            continue;\n                        }\n                        Poll::Ready(Ok(()))\n                    }\n                    Poll::Ready(Err(e)) => Poll::Ready(Err(e)),\n                    Poll::Pending => Poll::Pending,\n                };\n            }\n\n            // We need to get the next reader\n            if self.current_index >= self.readers.len() {\n                // No more readers\n                return Poll::Ready(Ok(()));\n            }\n\n            // Get the next reader\n            let reader_type = self.readers[self.current_index].clone();\n            self.current_index += 1;\n\n            match reader_type {\n                ReaderType::Bytes(bytes) => {\n                    self.current_reader = Some(Box::new(io::Cursor::new(bytes)));\n                }\n                ReaderType::FilePath(path) => {\n                    // We need to handle file opening synchronously in poll_read\n                    // This is a limitation - we'll use blocking file open\n                    match std::fs::File::open(&path) {\n                        Ok(file) => {\n                            // Convert std File to tokio File\n                            let tokio_file = tokio::fs::File::from_std(file);\n                            self.current_reader = Some(Box::new(tokio_file));\n                        }\n                        Err(e) => return Poll::Ready(Err(e)),\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/yaak-http/src/client.rs",
    "content": "use crate::dns::LocalhostResolver;\nuse crate::error::Result;\nuse log::{debug, info, warn};\nuse reqwest::{Client, Proxy, redirect};\nuse std::sync::Arc;\nuse yaak_models::models::DnsOverride;\nuse yaak_tls::{ClientCertificateConfig, get_tls_config};\n\n#[derive(Clone)]\npub struct HttpConnectionProxySettingAuth {\n    pub user: String,\n    pub password: String,\n}\n\n#[derive(Clone)]\npub enum HttpConnectionProxySetting {\n    Disabled,\n    System,\n    Enabled {\n        http: String,\n        https: String,\n        auth: Option<HttpConnectionProxySettingAuth>,\n        bypass: String,\n    },\n}\n\n#[derive(Clone)]\npub struct HttpConnectionOptions {\n    pub id: String,\n    pub validate_certificates: bool,\n    pub proxy: HttpConnectionProxySetting,\n    pub client_certificate: Option<ClientCertificateConfig>,\n    pub dns_overrides: Vec<DnsOverride>,\n}\n\nimpl HttpConnectionOptions {\n    /// Build a reqwest Client and return it along with the DNS resolver.\n    /// The resolver is returned separately so it can be configured per-request\n    /// to emit DNS timing events to the appropriate channel.\n    pub(crate) fn build_client(&self) -> Result<(Client, Arc<LocalhostResolver>)> {\n        let mut client = Client::builder()\n            .connection_verbose(true)\n            .redirect(redirect::Policy::none())\n            // Decompression is handled by HttpTransaction, not reqwest\n            .no_gzip()\n            .no_brotli()\n            .no_deflate()\n            .referer(false)\n            .tls_info(true)\n            // Disable connection pooling to ensure DNS resolution happens on each request\n            // This is needed so we can emit DNS timing events for each request\n            .pool_max_idle_per_host(0);\n\n        // Configure TLS with optional client certificate\n        let config =\n            get_tls_config(self.validate_certificates, true, self.client_certificate.clone())?;\n        client = client.use_preconfigured_tls(config);\n\n        // Configure DNS resolver - keep a reference to configure per-request\n        let resolver = LocalhostResolver::new(self.dns_overrides.clone());\n        client = client.dns_resolver(resolver.clone());\n\n        // Configure proxy\n        match self.proxy.clone() {\n            HttpConnectionProxySetting::System => { /* Default */ }\n            HttpConnectionProxySetting::Disabled => {\n                client = client.no_proxy();\n            }\n            HttpConnectionProxySetting::Enabled { http, https, auth, bypass } => {\n                for p in build_enabled_proxy(http, https, auth, bypass) {\n                    client = client.proxy(p)\n                }\n            }\n        }\n\n        info!(\n            \"Building new HTTP client validate_certificates={} client_cert={}\",\n            self.validate_certificates,\n            self.client_certificate.is_some()\n        );\n\n        Ok((client.build()?, resolver))\n    }\n}\n\nfn build_enabled_proxy(\n    http: String,\n    https: String,\n    auth: Option<HttpConnectionProxySettingAuth>,\n    bypass: String,\n) -> Vec<Proxy> {\n    debug!(\"Using proxy http={http} https={https} bypass={bypass}\");\n\n    let mut proxies = Vec::new();\n\n    if !http.is_empty() {\n        match Proxy::http(http) {\n            Ok(mut proxy) => {\n                if let Some(HttpConnectionProxySettingAuth { user, password }) = auth.clone() {\n                    debug!(\"Using http proxy auth\");\n                    proxy = proxy.basic_auth(user.as_str(), password.as_str());\n                }\n                proxies.push(proxy.no_proxy(reqwest::NoProxy::from_string(&bypass)));\n            }\n            Err(e) => {\n                warn!(\"Failed to apply http proxy {e:?}\");\n            }\n        };\n    }\n\n    if !https.is_empty() {\n        match Proxy::https(https) {\n            Ok(mut proxy) => {\n                if let Some(HttpConnectionProxySettingAuth { user, password }) = auth {\n                    debug!(\"Using https proxy auth\");\n                    proxy = proxy.basic_auth(user.as_str(), password.as_str());\n                }\n                proxies.push(proxy.no_proxy(reqwest::NoProxy::from_string(&bypass)));\n            }\n            Err(e) => {\n                warn!(\"Failed to apply https proxy {e:?}\");\n            }\n        };\n    }\n\n    proxies\n}\n"
  },
  {
    "path": "crates/yaak-http/src/cookies.rs",
    "content": "//! Custom cookie handling for HTTP requests\n//!\n//! This module provides cookie storage and matching functionality that was previously\n//! delegated to reqwest. It implements RFC 6265 cookie domain and path matching.\n\nuse log::debug;\nuse std::sync::{Arc, Mutex};\nuse std::time::{Duration, SystemTime, UNIX_EPOCH};\nuse url::Url;\nuse yaak_models::models::{Cookie, CookieDomain, CookieExpires};\n\n/// A thread-safe cookie store that can be shared across requests\n#[derive(Debug, Clone)]\npub struct CookieStore {\n    cookies: Arc<Mutex<Vec<Cookie>>>,\n}\n\nimpl Default for CookieStore {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl CookieStore {\n    /// Create a new empty cookie store\n    pub fn new() -> Self {\n        Self { cookies: Arc::new(Mutex::new(Vec::new())) }\n    }\n\n    /// Create a cookie store from existing cookies\n    pub fn from_cookies(cookies: Vec<Cookie>) -> Self {\n        Self { cookies: Arc::new(Mutex::new(cookies)) }\n    }\n\n    /// Get all cookies (for persistence)\n    pub fn get_all_cookies(&self) -> Vec<Cookie> {\n        self.cookies.lock().unwrap().clone()\n    }\n\n    /// Get the Cookie header value for the given URL\n    pub fn get_cookie_header(&self, url: &Url) -> Option<String> {\n        let cookies = self.cookies.lock().unwrap();\n        let now = SystemTime::now();\n\n        let matching_cookies: Vec<_> = cookies\n            .iter()\n            .filter(|cookie| self.cookie_matches(cookie, url, &now))\n            .filter_map(|cookie| {\n                // Parse the raw cookie to get name=value\n                parse_cookie_name_value(&cookie.raw_cookie)\n            })\n            .collect();\n\n        if matching_cookies.is_empty() {\n            None\n        } else {\n            Some(\n                matching_cookies\n                    .into_iter()\n                    .map(|(name, value)| format!(\"{}={}\", name, value))\n                    .collect::<Vec<_>>()\n                    .join(\"; \"),\n            )\n        }\n    }\n\n    /// Parse Set-Cookie headers and add cookies to the store\n    pub fn store_cookies_from_response(&self, url: &Url, set_cookie_headers: &[String]) {\n        let mut cookies = self.cookies.lock().unwrap();\n\n        for header_value in set_cookie_headers {\n            if let Some(cookie) = parse_set_cookie(header_value, url) {\n                // Remove any existing cookie with the same name and domain\n                cookies.retain(|existing| !cookies_match(existing, &cookie));\n                debug!(\n                    \"Storing cookie: {} for domain {:?}\",\n                    parse_cookie_name_value(&cookie.raw_cookie)\n                        .map(|(n, _)| n)\n                        .unwrap_or_else(|| \"unknown\".to_string()),\n                    cookie.domain\n                );\n                cookies.push(cookie);\n            }\n        }\n    }\n\n    /// Check if a cookie matches the given URL\n    fn cookie_matches(&self, cookie: &Cookie, url: &Url, now: &SystemTime) -> bool {\n        // Check expiration\n        if let CookieExpires::AtUtc(expiry_str) = &cookie.expires {\n            if let Ok(expiry) = parse_cookie_date(expiry_str) {\n                if expiry < *now {\n                    return false;\n                }\n            }\n        }\n\n        // Check domain\n        let url_host = match url.host_str() {\n            Some(h) => h.to_lowercase(),\n            None => return false,\n        };\n\n        let domain_matches = match &cookie.domain {\n            CookieDomain::HostOnly(domain) => url_host == domain.to_lowercase(),\n            CookieDomain::Suffix(domain) => {\n                let domain_lower = domain.to_lowercase();\n                url_host == domain_lower || url_host.ends_with(&format!(\".{}\", domain_lower))\n            }\n            // NotPresent and Empty should never occur in practice since we always set domain\n            // when parsing Set-Cookie headers. Treat as non-matching to be safe.\n            CookieDomain::NotPresent | CookieDomain::Empty => false,\n        };\n\n        if !domain_matches {\n            return false;\n        }\n\n        // Check path\n        let (cookie_path, _) = &cookie.path;\n        let url_path = url.path();\n\n        path_matches(url_path, cookie_path)\n    }\n}\n\n/// Parse name=value from a cookie string (raw_cookie format)\nfn parse_cookie_name_value(raw_cookie: &str) -> Option<(String, String)> {\n    // The raw_cookie typically looks like \"name=value\" or \"name=value; attr1; attr2=...\"\n    let first_part = raw_cookie.split(';').next()?;\n    let mut parts = first_part.splitn(2, '=');\n    let name = parts.next()?.trim().to_string();\n    let value = parts.next().unwrap_or(\"\").trim().to_string();\n\n    if name.is_empty() { None } else { Some((name, value)) }\n}\n\n/// Parse a Set-Cookie header into a Cookie\nfn parse_set_cookie(header_value: &str, request_url: &Url) -> Option<Cookie> {\n    let parsed = cookie::Cookie::parse(header_value).ok()?;\n\n    let raw_cookie = format!(\"{}={}\", parsed.name(), parsed.value());\n\n    // Determine domain\n    let domain = if let Some(domain_attr) = parsed.domain() {\n        // Domain attribute present - this is a suffix match\n        let domain = domain_attr.trim_start_matches('.').to_lowercase();\n\n        // Reject single-component domains (TLDs) except localhost\n        if is_single_component_domain(&domain) && !is_localhost(&domain) {\n            debug!(\"Rejecting cookie with single-component domain: {}\", domain);\n            return None;\n        }\n\n        CookieDomain::Suffix(domain)\n    } else {\n        // No domain attribute - host-only cookie\n        CookieDomain::HostOnly(request_url.host_str().unwrap_or(\"\").to_lowercase())\n    };\n\n    // Determine expiration\n    let expires = if let Some(max_age) = parsed.max_age() {\n        let duration = Duration::from_secs(max_age.whole_seconds().max(0) as u64);\n        let expiry = SystemTime::now() + duration;\n        let expiry_secs = expiry.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();\n        CookieExpires::AtUtc(format!(\"{}\", expiry_secs))\n    } else if let Some(expires_time) = parsed.expires() {\n        match expires_time {\n            cookie::Expiration::DateTime(dt) => {\n                let timestamp = dt.unix_timestamp();\n                CookieExpires::AtUtc(format!(\"{}\", timestamp))\n            }\n            cookie::Expiration::Session => CookieExpires::SessionEnd,\n        }\n    } else {\n        CookieExpires::SessionEnd\n    };\n\n    // Determine path\n    let path = if let Some(path_attr) = parsed.path() {\n        (path_attr.to_string(), true)\n    } else {\n        // Default path is the directory of the request URI\n        let default_path = default_cookie_path(request_url.path());\n        (default_path, false)\n    };\n\n    Some(Cookie { raw_cookie, domain, expires, path })\n}\n\n/// Get the default cookie path from a request path (RFC 6265 Section 5.1.4)\nfn default_cookie_path(request_path: &str) -> String {\n    if request_path.is_empty() || !request_path.starts_with('/') {\n        return \"/\".to_string();\n    }\n\n    // Find the last slash\n    if let Some(last_slash) = request_path.rfind('/') {\n        if last_slash == 0 { \"/\".to_string() } else { request_path[..last_slash].to_string() }\n    } else {\n        \"/\".to_string()\n    }\n}\n\n/// Check if a request path matches a cookie path (RFC 6265 Section 5.1.4)\nfn path_matches(request_path: &str, cookie_path: &str) -> bool {\n    if request_path == cookie_path {\n        return true;\n    }\n\n    if request_path.starts_with(cookie_path) {\n        // Cookie path must end with / or the char after cookie_path in request_path must be /\n        if cookie_path.ends_with('/') {\n            return true;\n        }\n        if request_path.chars().nth(cookie_path.len()) == Some('/') {\n            return true;\n        }\n    }\n\n    false\n}\n\n/// Check if two cookies match (same name and domain)\nfn cookies_match(a: &Cookie, b: &Cookie) -> bool {\n    let name_a = parse_cookie_name_value(&a.raw_cookie).map(|(n, _)| n);\n    let name_b = parse_cookie_name_value(&b.raw_cookie).map(|(n, _)| n);\n\n    if name_a != name_b {\n        return false;\n    }\n\n    // Check domain match\n    match (&a.domain, &b.domain) {\n        (CookieDomain::HostOnly(d1), CookieDomain::HostOnly(d2)) => {\n            d1.to_lowercase() == d2.to_lowercase()\n        }\n        (CookieDomain::Suffix(d1), CookieDomain::Suffix(d2)) => {\n            d1.to_lowercase() == d2.to_lowercase()\n        }\n        _ => false,\n    }\n}\n\n/// Parse a cookie date string (Unix timestamp in our format)\nfn parse_cookie_date(date_str: &str) -> Result<SystemTime, ()> {\n    let timestamp: i64 = date_str.parse().map_err(|_| ())?;\n    let duration = Duration::from_secs(timestamp.max(0) as u64);\n    Ok(UNIX_EPOCH + duration)\n}\n\n/// Check if a domain is a single-component domain (TLD)\n/// e.g., \"com\", \"org\", \"net\" - domains without any dots\nfn is_single_component_domain(domain: &str) -> bool {\n    // Empty or only dots\n    let trimmed = domain.trim_matches('.');\n    if trimmed.is_empty() {\n        return true;\n    }\n    // IPv6 addresses use colons, not dots - don't consider them single-component\n    if domain.contains(':') {\n        return false;\n    }\n    !trimmed.contains('.')\n}\n\n/// Check if a domain is localhost or a localhost variant\nfn is_localhost(domain: &str) -> bool {\n    let lower = domain.to_lowercase();\n    lower == \"localhost\"\n        || lower.ends_with(\".localhost\")\n        || lower == \"127.0.0.1\"\n        || lower == \"::1\"\n        || lower == \"[::1]\"\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_parse_cookie_name_value() {\n        assert_eq!(\n            parse_cookie_name_value(\"session=abc123\"),\n            Some((\"session\".to_string(), \"abc123\".to_string()))\n        );\n        assert_eq!(\n            parse_cookie_name_value(\"name=value; Path=/; HttpOnly\"),\n            Some((\"name\".to_string(), \"value\".to_string()))\n        );\n        assert_eq!(parse_cookie_name_value(\"empty=\"), Some((\"empty\".to_string(), \"\".to_string())));\n        assert_eq!(parse_cookie_name_value(\"\"), None);\n    }\n\n    #[test]\n    fn test_path_matches() {\n        assert!(path_matches(\"/\", \"/\"));\n        assert!(path_matches(\"/foo\", \"/\"));\n        assert!(path_matches(\"/foo/bar\", \"/foo\"));\n        assert!(path_matches(\"/foo/bar\", \"/foo/\"));\n        assert!(!path_matches(\"/foobar\", \"/foo\"));\n        assert!(!path_matches(\"/foo\", \"/foo/bar\"));\n    }\n\n    #[test]\n    fn test_default_cookie_path() {\n        assert_eq!(default_cookie_path(\"/\"), \"/\");\n        assert_eq!(default_cookie_path(\"/foo\"), \"/\");\n        assert_eq!(default_cookie_path(\"/foo/bar\"), \"/foo\");\n        assert_eq!(default_cookie_path(\"/foo/bar/baz\"), \"/foo/bar\");\n        assert_eq!(default_cookie_path(\"\"), \"/\");\n    }\n\n    #[test]\n    fn test_cookie_store_basic() {\n        let store = CookieStore::new();\n        let url = Url::parse(\"https://example.com/path\").unwrap();\n\n        // Initially empty\n        assert!(store.get_cookie_header(&url).is_none());\n\n        // Add a cookie\n        store.store_cookies_from_response(&url, &[\"session=abc123\".to_string()]);\n\n        // Should now have the cookie\n        let header = store.get_cookie_header(&url);\n        assert_eq!(header, Some(\"session=abc123\".to_string()));\n    }\n\n    #[test]\n    fn test_cookie_domain_matching() {\n        let store = CookieStore::new();\n        let url = Url::parse(\"https://example.com/\").unwrap();\n\n        // Cookie with domain attribute (suffix match)\n        store.store_cookies_from_response(\n            &url,\n            &[\"domain_cookie=value; Domain=example.com\".to_string()],\n        );\n\n        // Should match example.com\n        assert!(store.get_cookie_header(&url).is_some());\n\n        // Should match subdomain\n        let subdomain_url = Url::parse(\"https://sub.example.com/\").unwrap();\n        assert!(store.get_cookie_header(&subdomain_url).is_some());\n\n        // Should not match different domain\n        let other_url = Url::parse(\"https://other.com/\").unwrap();\n        assert!(store.get_cookie_header(&other_url).is_none());\n    }\n\n    #[test]\n    fn test_cookie_path_matching() {\n        let store = CookieStore::new();\n        let url = Url::parse(\"https://example.com/api/v1\").unwrap();\n\n        // Cookie with path\n        store.store_cookies_from_response(&url, &[\"api_cookie=value; Path=/api\".to_string()]);\n\n        // Should match /api/v1\n        assert!(store.get_cookie_header(&url).is_some());\n\n        // Should match /api\n        let api_url = Url::parse(\"https://example.com/api\").unwrap();\n        assert!(store.get_cookie_header(&api_url).is_some());\n\n        // Should not match /other\n        let other_url = Url::parse(\"https://example.com/other\").unwrap();\n        assert!(store.get_cookie_header(&other_url).is_none());\n    }\n\n    #[test]\n    fn test_cookie_replacement() {\n        let store = CookieStore::new();\n        let url = Url::parse(\"https://example.com/\").unwrap();\n\n        // Add a cookie\n        store.store_cookies_from_response(&url, &[\"session=old\".to_string()]);\n        assert_eq!(store.get_cookie_header(&url), Some(\"session=old\".to_string()));\n\n        // Replace with new value\n        store.store_cookies_from_response(&url, &[\"session=new\".to_string()]);\n        assert_eq!(store.get_cookie_header(&url), Some(\"session=new\".to_string()));\n\n        // Should only have one cookie\n        assert_eq!(store.get_all_cookies().len(), 1);\n    }\n\n    #[test]\n    fn test_is_single_component_domain() {\n        // Single-component domains (TLDs)\n        assert!(is_single_component_domain(\"com\"));\n        assert!(is_single_component_domain(\"org\"));\n        assert!(is_single_component_domain(\"net\"));\n        assert!(is_single_component_domain(\"localhost\")); // Still single-component, but allowed separately\n\n        // Multi-component domains\n        assert!(!is_single_component_domain(\"example.com\"));\n        assert!(!is_single_component_domain(\"sub.example.com\"));\n        assert!(!is_single_component_domain(\"co.uk\"));\n\n        // Edge cases\n        assert!(is_single_component_domain(\"\")); // Empty is treated as single-component\n        assert!(is_single_component_domain(\".\")); // Only dots\n        assert!(is_single_component_domain(\"..\")); // Only dots\n\n        // IPv6 addresses (have colons, not dots)\n        assert!(!is_single_component_domain(\"::1\")); // IPv6 localhost\n        assert!(!is_single_component_domain(\"[::1]\")); // Bracketed IPv6\n        assert!(!is_single_component_domain(\"2001:db8::1\")); // IPv6 address\n    }\n\n    #[test]\n    fn test_is_localhost() {\n        // Localhost variants\n        assert!(is_localhost(\"localhost\"));\n        assert!(is_localhost(\"LOCALHOST\")); // Case-insensitive\n        assert!(is_localhost(\"sub.localhost\"));\n        assert!(is_localhost(\"app.sub.localhost\"));\n\n        // IP localhost\n        assert!(is_localhost(\"127.0.0.1\"));\n        assert!(is_localhost(\"::1\"));\n        assert!(is_localhost(\"[::1]\"));\n\n        // Not localhost\n        assert!(!is_localhost(\"example.com\"));\n        assert!(!is_localhost(\"localhost.com\")); // .com domain, not localhost\n        assert!(!is_localhost(\"notlocalhost\"));\n    }\n\n    #[test]\n    fn test_reject_tld_cookies() {\n        let store = CookieStore::new();\n        let url = Url::parse(\"https://example.com/\").unwrap();\n\n        // Try to set a cookie with Domain=com (TLD)\n        store.store_cookies_from_response(&url, &[\"bad=cookie; Domain=com\".to_string()]);\n\n        // Should be rejected - no cookies stored\n        assert_eq!(store.get_all_cookies().len(), 0);\n        assert!(store.get_cookie_header(&url).is_none());\n    }\n\n    #[test]\n    fn test_allow_localhost_cookies() {\n        let store = CookieStore::new();\n        let url = Url::parse(\"http://localhost:3000/\").unwrap();\n\n        // Cookie with Domain=localhost should be allowed\n        store.store_cookies_from_response(&url, &[\"session=abc; Domain=localhost\".to_string()]);\n\n        // Should be accepted\n        assert_eq!(store.get_all_cookies().len(), 1);\n        assert!(store.get_cookie_header(&url).is_some());\n    }\n\n    #[test]\n    fn test_allow_127_0_0_1_cookies() {\n        let store = CookieStore::new();\n        let url = Url::parse(\"http://127.0.0.1:8080/\").unwrap();\n\n        // Cookie without Domain attribute (host-only) should work\n        store.store_cookies_from_response(&url, &[\"session=xyz\".to_string()]);\n\n        // Should be accepted\n        assert_eq!(store.get_all_cookies().len(), 1);\n        assert!(store.get_cookie_header(&url).is_some());\n    }\n\n    #[test]\n    fn test_allow_normal_domain_cookies() {\n        let store = CookieStore::new();\n        let url = Url::parse(\"https://example.com/\").unwrap();\n\n        // Cookie with valid domain should be allowed\n        store.store_cookies_from_response(&url, &[\"session=abc; Domain=example.com\".to_string()]);\n\n        // Should be accepted\n        assert_eq!(store.get_all_cookies().len(), 1);\n        assert!(store.get_cookie_header(&url).is_some());\n    }\n}\n"
  },
  {
    "path": "crates/yaak-http/src/decompress.rs",
    "content": "use crate::error::{Error, Result};\nuse async_compression::tokio::bufread::{\n    BrotliDecoder, DeflateDecoder as AsyncDeflateDecoder, GzipDecoder,\n    ZstdDecoder as AsyncZstdDecoder,\n};\nuse flate2::read::{DeflateDecoder, GzDecoder};\nuse std::io::Read;\nuse tokio::io::{AsyncBufRead, AsyncRead};\n\n/// Supported compression encodings\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum ContentEncoding {\n    Gzip,\n    Deflate,\n    Brotli,\n    Zstd,\n    Identity,\n}\n\nimpl ContentEncoding {\n    /// Parse a Content-Encoding header value into an encoding type.\n    /// Returns Identity for unknown or missing encodings.\n    pub fn from_header(value: Option<&str>) -> Self {\n        match value.map(|s| s.trim().to_lowercase()).as_deref() {\n            Some(\"gzip\") | Some(\"x-gzip\") => ContentEncoding::Gzip,\n            Some(\"deflate\") => ContentEncoding::Deflate,\n            Some(\"br\") => ContentEncoding::Brotli,\n            Some(\"zstd\") => ContentEncoding::Zstd,\n            _ => ContentEncoding::Identity,\n        }\n    }\n}\n\n/// Result of decompression, containing both the decompressed data and size info\n#[derive(Debug)]\npub struct DecompressResult {\n    pub data: Vec<u8>,\n    pub compressed_size: u64,\n    pub decompressed_size: u64,\n}\n\n/// Decompress data based on the Content-Encoding.\n/// Returns the original data unchanged if encoding is Identity or unknown.\npub fn decompress(data: Vec<u8>, encoding: ContentEncoding) -> Result<DecompressResult> {\n    let compressed_size = data.len() as u64;\n\n    let decompressed = match encoding {\n        ContentEncoding::Identity => data,\n        ContentEncoding::Gzip => decompress_gzip(&data)?,\n        ContentEncoding::Deflate => decompress_deflate(&data)?,\n        ContentEncoding::Brotli => decompress_brotli(&data)?,\n        ContentEncoding::Zstd => decompress_zstd(&data)?,\n    };\n\n    let decompressed_size = decompressed.len() as u64;\n\n    Ok(DecompressResult { data: decompressed, compressed_size, decompressed_size })\n}\n\nfn decompress_gzip(data: &[u8]) -> Result<Vec<u8>> {\n    let mut decoder = GzDecoder::new(data);\n    let mut decompressed = Vec::new();\n    decoder\n        .read_to_end(&mut decompressed)\n        .map_err(|e| Error::DecompressionError(format!(\"gzip decompression failed: {}\", e)))?;\n    Ok(decompressed)\n}\n\nfn decompress_deflate(data: &[u8]) -> Result<Vec<u8>> {\n    let mut decoder = DeflateDecoder::new(data);\n    let mut decompressed = Vec::new();\n    decoder\n        .read_to_end(&mut decompressed)\n        .map_err(|e| Error::DecompressionError(format!(\"deflate decompression failed: {}\", e)))?;\n    Ok(decompressed)\n}\n\nfn decompress_brotli(data: &[u8]) -> Result<Vec<u8>> {\n    let mut decompressed = Vec::new();\n    brotli::BrotliDecompress(&mut std::io::Cursor::new(data), &mut decompressed)\n        .map_err(|e| Error::DecompressionError(format!(\"brotli decompression failed: {}\", e)))?;\n    Ok(decompressed)\n}\n\nfn decompress_zstd(data: &[u8]) -> Result<Vec<u8>> {\n    zstd::stream::decode_all(std::io::Cursor::new(data))\n        .map_err(|e| Error::DecompressionError(format!(\"zstd decompression failed: {}\", e)))\n}\n\n/// Create a streaming decompressor that wraps an async reader.\n/// Returns an AsyncRead that decompresses data on-the-fly.\npub fn streaming_decoder<R: AsyncBufRead + Unpin + Send + 'static>(\n    reader: R,\n    encoding: ContentEncoding,\n) -> Box<dyn AsyncRead + Unpin + Send> {\n    match encoding {\n        ContentEncoding::Identity => Box::new(reader),\n        ContentEncoding::Gzip => Box::new(GzipDecoder::new(reader)),\n        ContentEncoding::Deflate => Box::new(AsyncDeflateDecoder::new(reader)),\n        ContentEncoding::Brotli => Box::new(BrotliDecoder::new(reader)),\n        ContentEncoding::Zstd => Box::new(AsyncZstdDecoder::new(reader)),\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use flate2::Compression;\n    use flate2::write::GzEncoder;\n    use std::io::Write;\n\n    #[test]\n    fn test_content_encoding_from_header() {\n        assert_eq!(ContentEncoding::from_header(Some(\"gzip\")), ContentEncoding::Gzip);\n        assert_eq!(ContentEncoding::from_header(Some(\"x-gzip\")), ContentEncoding::Gzip);\n        assert_eq!(ContentEncoding::from_header(Some(\"GZIP\")), ContentEncoding::Gzip);\n        assert_eq!(ContentEncoding::from_header(Some(\"deflate\")), ContentEncoding::Deflate);\n        assert_eq!(ContentEncoding::from_header(Some(\"br\")), ContentEncoding::Brotli);\n        assert_eq!(ContentEncoding::from_header(Some(\"zstd\")), ContentEncoding::Zstd);\n        assert_eq!(ContentEncoding::from_header(Some(\"identity\")), ContentEncoding::Identity);\n        assert_eq!(ContentEncoding::from_header(Some(\"unknown\")), ContentEncoding::Identity);\n        assert_eq!(ContentEncoding::from_header(None), ContentEncoding::Identity);\n    }\n\n    #[test]\n    fn test_decompress_identity() {\n        let data = b\"hello world\".to_vec();\n        let result = decompress(data.clone(), ContentEncoding::Identity).unwrap();\n        assert_eq!(result.data, data);\n        assert_eq!(result.compressed_size, 11);\n        assert_eq!(result.decompressed_size, 11);\n    }\n\n    #[test]\n    fn test_decompress_gzip() {\n        // Compress some data with gzip\n        let original = b\"hello world, this is a test of gzip compression\";\n        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());\n        encoder.write_all(original).unwrap();\n        let compressed = encoder.finish().unwrap();\n\n        let result = decompress(compressed.clone(), ContentEncoding::Gzip).unwrap();\n        assert_eq!(result.data, original);\n        assert_eq!(result.compressed_size, compressed.len() as u64);\n        assert_eq!(result.decompressed_size, original.len() as u64);\n    }\n\n    #[test]\n    fn test_decompress_deflate() {\n        // Compress some data with deflate\n        let original = b\"hello world, this is a test of deflate compression\";\n        let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), Compression::default());\n        encoder.write_all(original).unwrap();\n        let compressed = encoder.finish().unwrap();\n\n        let result = decompress(compressed.clone(), ContentEncoding::Deflate).unwrap();\n        assert_eq!(result.data, original);\n        assert_eq!(result.compressed_size, compressed.len() as u64);\n        assert_eq!(result.decompressed_size, original.len() as u64);\n    }\n\n    #[test]\n    fn test_decompress_brotli() {\n        // Compress some data with brotli\n        let original = b\"hello world, this is a test of brotli compression\";\n        let mut compressed = Vec::new();\n        let mut writer = brotli::CompressorWriter::new(&mut compressed, 4096, 4, 22);\n        writer.write_all(original).unwrap();\n        drop(writer);\n\n        let result = decompress(compressed.clone(), ContentEncoding::Brotli).unwrap();\n        assert_eq!(result.data, original);\n        assert_eq!(result.compressed_size, compressed.len() as u64);\n        assert_eq!(result.decompressed_size, original.len() as u64);\n    }\n\n    #[test]\n    fn test_decompress_zstd() {\n        // Compress some data with zstd\n        let original = b\"hello world, this is a test of zstd compression\";\n        let compressed = zstd::stream::encode_all(std::io::Cursor::new(original), 3).unwrap();\n\n        let result = decompress(compressed.clone(), ContentEncoding::Zstd).unwrap();\n        assert_eq!(result.data, original);\n        assert_eq!(result.compressed_size, compressed.len() as u64);\n        assert_eq!(result.decompressed_size, original.len() as u64);\n    }\n}\n"
  },
  {
    "path": "crates/yaak-http/src/dns.rs",
    "content": "use crate::sender::HttpResponseEvent;\nuse hyper_util::client::legacy::connect::dns::{\n    GaiResolver as HyperGaiResolver, Name as HyperName,\n};\nuse log::info;\nuse reqwest::dns::{Addrs, Name, Resolve, Resolving};\nuse std::collections::HashMap;\nuse std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse std::time::Instant;\nuse tokio::sync::{RwLock, mpsc};\nuse tower_service::Service;\nuse yaak_models::models::DnsOverride;\n\n/// Stores resolved addresses for a hostname override\n#[derive(Clone)]\npub struct ResolvedOverride {\n    pub ipv4: Vec<Ipv4Addr>,\n    pub ipv6: Vec<Ipv6Addr>,\n}\n\n#[derive(Clone)]\npub struct LocalhostResolver {\n    fallback: HyperGaiResolver,\n    event_tx: Arc<RwLock<Option<mpsc::Sender<HttpResponseEvent>>>>,\n    overrides: Arc<HashMap<String, ResolvedOverride>>,\n}\n\nimpl LocalhostResolver {\n    pub fn new(dns_overrides: Vec<DnsOverride>) -> Arc<Self> {\n        let resolver = HyperGaiResolver::new();\n\n        // Pre-parse DNS overrides into a lookup map\n        let mut overrides = HashMap::new();\n        for o in dns_overrides {\n            if !o.enabled {\n                continue;\n            }\n            let hostname = o.hostname.to_lowercase();\n\n            let ipv4: Vec<Ipv4Addr> =\n                o.ipv4.iter().filter_map(|s| s.parse::<Ipv4Addr>().ok()).collect();\n\n            let ipv6: Vec<Ipv6Addr> =\n                o.ipv6.iter().filter_map(|s| s.parse::<Ipv6Addr>().ok()).collect();\n\n            // Only add if at least one address is valid\n            if !ipv4.is_empty() || !ipv6.is_empty() {\n                overrides.insert(hostname, ResolvedOverride { ipv4, ipv6 });\n            }\n        }\n\n        Arc::new(Self {\n            fallback: resolver,\n            event_tx: Arc::new(RwLock::new(None)),\n            overrides: Arc::new(overrides),\n        })\n    }\n\n    /// Set the event sender for the current request.\n    /// This should be called before each request to direct DNS events\n    /// to the appropriate channel.\n    pub async fn set_event_sender(&self, tx: Option<mpsc::Sender<HttpResponseEvent>>) {\n        let mut guard = self.event_tx.write().await;\n        *guard = tx;\n    }\n}\n\nimpl Resolve for LocalhostResolver {\n    fn resolve(&self, name: Name) -> Resolving {\n        let host = name.as_str().to_lowercase();\n        let event_tx = self.event_tx.clone();\n        let overrides = self.overrides.clone();\n\n        info!(\"DNS resolve called for: {}\", host);\n\n        // Check for DNS override first\n        if let Some(resolved) = overrides.get(&host) {\n            log::debug!(\"DNS override found for: {}\", host);\n            let hostname = host.clone();\n            let mut addrs: Vec<SocketAddr> = Vec::new();\n\n            // Add IPv4 addresses\n            for ip in &resolved.ipv4 {\n                addrs.push(SocketAddr::new(IpAddr::V4(*ip), 0));\n            }\n\n            // Add IPv6 addresses\n            for ip in &resolved.ipv6 {\n                addrs.push(SocketAddr::new(IpAddr::V6(*ip), 0));\n            }\n\n            let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();\n\n            return Box::pin(async move {\n                // Emit DNS event for override\n                let guard = event_tx.read().await;\n                if let Some(tx) = guard.as_ref() {\n                    let _ = tx\n                        .send(HttpResponseEvent::DnsResolved {\n                            hostname,\n                            addresses,\n                            duration: 0,\n                            overridden: true,\n                        })\n                        .await;\n                }\n\n                Ok::<Addrs, Box<dyn std::error::Error + Send + Sync>>(Box::new(addrs.into_iter()))\n            });\n        }\n\n        // Check for .localhost suffix\n        let is_localhost = host.ends_with(\".localhost\");\n        if is_localhost {\n            let hostname = host.clone();\n            // Port 0 is fine; reqwest replaces it with the URL's explicit\n            // port or the scheme's default (80/443, etc.).\n            let addrs: Vec<SocketAddr> = vec![\n                SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),\n                SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),\n            ];\n\n            let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();\n\n            return Box::pin(async move {\n                // Emit DNS event for localhost resolution\n                let guard = event_tx.read().await;\n                if let Some(tx) = guard.as_ref() {\n                    let _ = tx\n                        .send(HttpResponseEvent::DnsResolved {\n                            hostname,\n                            addresses,\n                            duration: 0,\n                            overridden: false,\n                        })\n                        .await;\n                }\n\n                Ok::<Addrs, Box<dyn std::error::Error + Send + Sync>>(Box::new(addrs.into_iter()))\n            });\n        }\n\n        // Fall back to system DNS\n        let mut fallback = self.fallback.clone();\n        let name_str = name.as_str().to_string();\n        let hostname = host.clone();\n\n        Box::pin(async move {\n            let start = Instant::now();\n\n            let result = match HyperName::from_str(&name_str) {\n                Ok(n) => fallback.call(n).await,\n                Err(e) => return Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>),\n            };\n\n            let duration = start.elapsed().as_millis() as u64;\n\n            match result {\n                Ok(addrs) => {\n                    // Collect addresses for event emission\n                    let addr_vec: Vec<SocketAddr> = addrs.collect();\n                    let addresses: Vec<String> =\n                        addr_vec.iter().map(|a| a.ip().to_string()).collect();\n\n                    // Emit DNS event\n                    let guard = event_tx.read().await;\n                    if let Some(tx) = guard.as_ref() {\n                        let _ = tx\n                            .send(HttpResponseEvent::DnsResolved {\n                                hostname,\n                                addresses,\n                                duration,\n                                overridden: false,\n                            })\n                            .await;\n                    }\n\n                    Ok(Box::new(addr_vec.into_iter()) as Addrs)\n                }\n                Err(err) => Err(Box::new(err) as Box<dyn std::error::Error + Send + Sync>),\n            }\n        })\n    }\n}\n"
  },
  {
    "path": "crates/yaak-http/src/error.rs",
    "content": "use serde::{Serialize, Serializer};\nuse thiserror::Error;\n\n#[derive(Error, Debug)]\npub enum Error {\n    #[error(\"Client error: {0:?}\")]\n    Client(#[from] reqwest::Error),\n\n    #[error(transparent)]\n    TlsError(#[from] yaak_tls::error::Error),\n\n    #[error(\"Request failed with {0:?}\")]\n    RequestError(String),\n\n    #[error(\"Request canceled\")]\n    RequestCanceledError,\n\n    #[error(\"Timeout of {0:?} reached\")]\n    RequestTimeout(std::time::Duration),\n\n    #[error(\"Decompression error: {0}\")]\n    DecompressionError(String),\n\n    #[error(\"Failed to read response body: {0}\")]\n    BodyReadError(String),\n}\n\nimpl Serialize for Error {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        serializer.serialize_str(self.to_string().as_ref())\n    }\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates/yaak-http/src/lib.rs",
    "content": "mod chained_reader;\npub mod client;\npub mod cookies;\npub mod decompress;\npub mod dns;\npub mod error;\npub mod manager;\npub mod path_placeholders;\nmod proto;\npub mod sender;\npub mod tee_reader;\npub mod transaction;\npub mod types;\n"
  },
  {
    "path": "crates/yaak-http/src/manager.rs",
    "content": "use crate::client::HttpConnectionOptions;\nuse crate::dns::LocalhostResolver;\nuse crate::error::Result;\nuse reqwest::Client;\nuse std::collections::BTreeMap;\nuse std::sync::Arc;\nuse std::time::{Duration, Instant};\nuse tokio::sync::RwLock;\n\n/// A cached HTTP client along with its DNS resolver.\n/// The resolver is needed to set the event sender per-request.\npub struct CachedClient {\n    pub client: Client,\n    pub resolver: Arc<LocalhostResolver>,\n}\n\npub struct HttpConnectionManager {\n    connections: Arc<RwLock<BTreeMap<String, (CachedClient, Instant)>>>,\n    ttl: Duration,\n}\n\nimpl HttpConnectionManager {\n    pub fn new() -> Self {\n        Self {\n            connections: Arc::new(RwLock::new(BTreeMap::new())),\n            ttl: Duration::from_secs(10 * 60),\n        }\n    }\n\n    pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<CachedClient> {\n        let mut connections = self.connections.write().await;\n        let id = opt.id.clone();\n\n        // Clean old connections\n        connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);\n\n        if let Some((cached, last_used)) = connections.get_mut(&id) {\n            *last_used = Instant::now();\n            return Ok(CachedClient {\n                client: cached.client.clone(),\n                resolver: cached.resolver.clone(),\n            });\n        }\n\n        let (client, resolver) = opt.build_client()?;\n        let cached = CachedClient { client: client.clone(), resolver: resolver.clone() };\n        connections.insert(id.into(), (cached, Instant::now()));\n\n        Ok(CachedClient { client, resolver })\n    }\n}\n"
  },
  {
    "path": "crates/yaak-http/src/path_placeholders.rs",
    "content": "use yaak_models::models::HttpUrlParameter;\n\npub fn apply_path_placeholders(\n    url: &str,\n    parameters: &Vec<HttpUrlParameter>,\n) -> (String, Vec<HttpUrlParameter>) {\n    let mut new_parameters = Vec::new();\n\n    let mut url = url.to_string();\n    for p in parameters {\n        if !p.enabled || p.name.is_empty() {\n            continue;\n        }\n\n        // Replace path parameters with values from URL parameters\n        let old_url_string = url.clone();\n        url = replace_path_placeholder(&p, url.as_str());\n\n        // Remove as param if it modified the URL\n        if old_url_string == *url {\n            new_parameters.push(p.to_owned());\n        }\n    }\n\n    (url, new_parameters)\n}\n\nfn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {\n    if !p.enabled {\n        return url.to_string();\n    }\n\n    if !p.name.starts_with(\":\") {\n        return url.to_string();\n    }\n\n    let re = regex::Regex::new(format!(\"(/){}([/?#]|$)\", p.name).as_str()).unwrap();\n    let result = re\n        .replace_all(url, |cap: &regex::Captures| {\n            format!(\n                \"{}{}{}\",\n                cap[1].to_string(),\n                urlencoding::encode(p.value.as_str()),\n                cap[2].to_string()\n            )\n        })\n        .into_owned();\n    result\n}\n\n#[cfg(test)]\nmod placeholder_tests {\n    use crate::path_placeholders::{apply_path_placeholders, replace_path_placeholder};\n    use yaak_models::models::{HttpRequest, HttpUrlParameter};\n\n    #[test]\n    fn placeholder_middle() {\n        let p =\n            HttpUrlParameter { name: \":foo\".into(), value: \"xxx\".into(), enabled: true, id: None };\n        assert_eq!(\n            replace_path_placeholder(&p, \"https://example.com/:foo/bar\"),\n            \"https://example.com/xxx/bar\",\n        );\n    }\n\n    #[test]\n    fn placeholder_end() {\n        let p =\n            HttpUrlParameter { name: \":foo\".into(), value: \"xxx\".into(), enabled: true, id: None };\n        assert_eq!(\n            replace_path_placeholder(&p, \"https://example.com/:foo\"),\n            \"https://example.com/xxx\",\n        );\n    }\n\n    #[test]\n    fn placeholder_query() {\n        let p =\n            HttpUrlParameter { name: \":foo\".into(), value: \"xxx\".into(), enabled: true, id: None };\n        assert_eq!(\n            replace_path_placeholder(&p, \"https://example.com/:foo?:foo\"),\n            \"https://example.com/xxx?:foo\",\n        );\n    }\n\n    #[test]\n    fn placeholder_missing() {\n        let p = HttpUrlParameter {\n            enabled: true,\n            name: \"\".to_string(),\n            value: \"\".to_string(),\n            id: None,\n        };\n        assert_eq!(\n            replace_path_placeholder(&p, \"https://example.com/:missing\"),\n            \"https://example.com/:missing\",\n        );\n    }\n\n    #[test]\n    fn placeholder_disabled() {\n        let p = HttpUrlParameter {\n            enabled: false,\n            name: \":foo\".to_string(),\n            value: \"xxx\".to_string(),\n            id: None,\n        };\n        assert_eq!(\n            replace_path_placeholder(&p, \"https://example.com/:foo\"),\n            \"https://example.com/:foo\",\n        );\n    }\n\n    #[test]\n    fn placeholder_prefix() {\n        let p =\n            HttpUrlParameter { name: \":foo\".into(), value: \"xxx\".into(), enabled: true, id: None };\n        assert_eq!(\n            replace_path_placeholder(&p, \"https://example.com/:foooo\"),\n            \"https://example.com/:foooo\",\n        );\n    }\n\n    #[test]\n    fn placeholder_encode() {\n        let p = HttpUrlParameter {\n            name: \":foo\".into(),\n            value: \"Hello World\".into(),\n            enabled: true,\n            id: None,\n        };\n        assert_eq!(\n            replace_path_placeholder(&p, \"https://example.com/:foo\"),\n            \"https://example.com/Hello%20World\",\n        );\n    }\n\n    #[test]\n    fn apply_placeholder() {\n        let req = HttpRequest {\n            url: \"example.com/:a/bar\".to_string(),\n            url_parameters: vec![\n                HttpUrlParameter {\n                    name: \"b\".to_string(),\n                    value: \"bbb\".to_string(),\n                    enabled: true,\n                    id: None,\n                },\n                HttpUrlParameter {\n                    name: \":a\".to_string(),\n                    value: \"aaa\".to_string(),\n                    enabled: true,\n                    id: None,\n                },\n            ],\n            ..Default::default()\n        };\n\n        let (url, url_parameters) = apply_path_placeholders(&req.url, &req.url_parameters);\n\n        // Pattern match back to access it\n        assert_eq!(url, \"example.com/aaa/bar\");\n        assert_eq!(url_parameters.len(), 1);\n        assert_eq!(url_parameters[0].name, \"b\");\n        assert_eq!(url_parameters[0].value, \"bbb\");\n    }\n}\n"
  },
  {
    "path": "crates/yaak-http/src/proto.rs",
    "content": "use reqwest::Url;\nuse std::str::FromStr;\n\npub(crate) fn ensure_proto(url_str: &str) -> String {\n    if url_str.is_empty() {\n        return \"\".to_string();\n    }\n\n    if url_str.starts_with(\"http://\") || url_str.starts_with(\"https://\") {\n        return url_str.to_string();\n    }\n\n    // Url::from_str will fail without a proto, so add one\n    let parseable_url = format!(\"http://{}\", url_str);\n    if let Ok(u) = Url::from_str(parseable_url.as_str()) {\n        match u.host() {\n            Some(host) => {\n                let h = host.to_string();\n                // These TLDs force HTTPS\n                if h.ends_with(\".app\") || h.ends_with(\".dev\") || h.ends_with(\".page\") {\n                    return format!(\"https://{url_str}\");\n                }\n            }\n            None => {}\n        }\n    }\n\n    format!(\"http://{url_str}\")\n}\n"
  },
  {
    "path": "crates/yaak-http/src/sender.rs",
    "content": "use crate::decompress::{ContentEncoding, streaming_decoder};\nuse crate::error::{Error, Result};\nuse crate::types::{SendableBody, SendableHttpRequest};\nuse async_trait::async_trait;\nuse bytes::Bytes;\nuse futures_util::StreamExt;\nuse http_body::{Body as HttpBody, Frame, SizeHint};\nuse reqwest::{Client, Method, Version};\nuse std::fmt::Display;\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\nuse std::time::Duration;\nuse tokio::io::{AsyncRead, AsyncReadExt, BufReader, ReadBuf};\nuse tokio::sync::mpsc;\nuse tokio_util::io::StreamReader;\n\n#[derive(Debug, Clone)]\npub enum RedirectBehavior {\n    /// 307/308: Method and body are preserved\n    Preserve,\n    /// 303 or 301/302 with POST: Method changed to GET, body dropped\n    DropBody,\n}\n\n#[derive(Debug, Clone)]\npub enum HttpResponseEvent {\n    Setting(String, String),\n    Info(String),\n    Redirect {\n        url: String,\n        status: u16,\n        behavior: RedirectBehavior,\n        dropped_body: bool,\n        dropped_headers: Vec<String>,\n    },\n    SendUrl {\n        method: String,\n        scheme: String,\n        username: String,\n        password: String,\n        host: String,\n        port: u16,\n        path: String,\n        query: String,\n        fragment: String,\n    },\n    ReceiveUrl {\n        version: Version,\n        status: String,\n    },\n    HeaderUp(String, String),\n    HeaderDown(String, String),\n    ChunkSent {\n        bytes: usize,\n    },\n    ChunkReceived {\n        bytes: usize,\n    },\n    DnsResolved {\n        hostname: String,\n        addresses: Vec<String>,\n        duration: u64,\n        overridden: bool,\n    },\n}\n\nimpl Display for HttpResponseEvent {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            HttpResponseEvent::Setting(name, value) => write!(f, \"* Setting {}={}\", name, value),\n            HttpResponseEvent::Info(s) => write!(f, \"* {}\", s),\n            HttpResponseEvent::Redirect {\n                url,\n                status,\n                behavior,\n                dropped_body,\n                dropped_headers,\n            } => {\n                let behavior_str = match behavior {\n                    RedirectBehavior::Preserve => \"preserve\",\n                    RedirectBehavior::DropBody => \"drop body\",\n                };\n                let body_str = if *dropped_body { \", body dropped\" } else { \"\" };\n                let headers_str = if dropped_headers.is_empty() {\n                    String::new()\n                } else {\n                    format!(\", headers dropped: {}\", dropped_headers.join(\", \"))\n                };\n                write!(\n                    f,\n                    \"* Redirect {} -> {} ({}{}{})\",\n                    status, url, behavior_str, body_str, headers_str\n                )\n            }\n            HttpResponseEvent::SendUrl {\n                method,\n                scheme,\n                username,\n                password,\n                host,\n                port,\n                path,\n                query,\n                fragment,\n            } => {\n                let auth_str = if username.is_empty() && password.is_empty() {\n                    String::new()\n                } else {\n                    format!(\"{}:{}@\", username, password)\n                };\n                let query_str =\n                    if query.is_empty() { String::new() } else { format!(\"?{}\", query) };\n                let fragment_str =\n                    if fragment.is_empty() { String::new() } else { format!(\"#{}\", fragment) };\n                write!(\n                    f,\n                    \"> {} {}://{}{}:{}{}{}{}\",\n                    method, scheme, auth_str, host, port, path, query_str, fragment_str\n                )\n            }\n            HttpResponseEvent::ReceiveUrl { version, status } => {\n                write!(f, \"< {} {}\", version_to_str(version), status)\n            }\n            HttpResponseEvent::HeaderUp(name, value) => write!(f, \"> {}: {}\", name, value),\n            HttpResponseEvent::HeaderDown(name, value) => write!(f, \"< {}: {}\", name, value),\n            HttpResponseEvent::ChunkSent { bytes } => write!(f, \"> [{} bytes sent]\", bytes),\n            HttpResponseEvent::ChunkReceived { bytes } => write!(f, \"< [{} bytes received]\", bytes),\n            HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden } => {\n                if *overridden {\n                    write!(f, \"* DNS override {} -> {}\", hostname, addresses.join(\", \"))\n                } else {\n                    write!(\n                        f,\n                        \"* DNS resolved {} to {} ({}ms)\",\n                        hostname,\n                        addresses.join(\", \"),\n                        duration\n                    )\n                }\n            }\n        }\n    }\n}\n\nimpl From<HttpResponseEvent> for yaak_models::models::HttpResponseEventData {\n    fn from(event: HttpResponseEvent) -> Self {\n        use yaak_models::models::HttpResponseEventData as D;\n        match event {\n            HttpResponseEvent::Setting(name, value) => D::Setting { name, value },\n            HttpResponseEvent::Info(message) => D::Info { message },\n            HttpResponseEvent::Redirect {\n                url,\n                status,\n                behavior,\n                dropped_body,\n                dropped_headers,\n            } => D::Redirect {\n                url,\n                status,\n                behavior: match behavior {\n                    RedirectBehavior::Preserve => \"preserve\".to_string(),\n                    RedirectBehavior::DropBody => \"drop_body\".to_string(),\n                },\n                dropped_body,\n                dropped_headers,\n            },\n            HttpResponseEvent::SendUrl {\n                method,\n                scheme,\n                username,\n                password,\n                host,\n                port,\n                path,\n                query,\n                fragment,\n            } => {\n                D::SendUrl { method, scheme, username, password, host, port, path, query, fragment }\n            }\n            HttpResponseEvent::ReceiveUrl { version, status } => {\n                D::ReceiveUrl { version: format!(\"{:?}\", version), status }\n            }\n            HttpResponseEvent::HeaderUp(name, value) => D::HeaderUp { name, value },\n            HttpResponseEvent::HeaderDown(name, value) => D::HeaderDown { name, value },\n            HttpResponseEvent::ChunkSent { bytes } => D::ChunkSent { bytes },\n            HttpResponseEvent::ChunkReceived { bytes } => D::ChunkReceived { bytes },\n            HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden } => {\n                D::DnsResolved { hostname, addresses, duration, overridden }\n            }\n        }\n    }\n}\n\n/// Statistics about the body after consumption\n#[derive(Debug, Default, Clone)]\npub struct BodyStats {\n    /// Size of the body as received over the wire (before decompression)\n    pub size_compressed: u64,\n    /// Size of the body after decompression\n    pub size_decompressed: u64,\n}\n\n/// An AsyncRead wrapper that sends chunk events as data is read\npub struct TrackingRead<R> {\n    inner: R,\n    event_tx: mpsc::Sender<HttpResponseEvent>,\n    ended: bool,\n}\n\nimpl<R> TrackingRead<R> {\n    pub fn new(inner: R, event_tx: mpsc::Sender<HttpResponseEvent>) -> Self {\n        Self { inner, event_tx, ended: false }\n    }\n}\n\nimpl<R: AsyncRead + Unpin> AsyncRead for TrackingRead<R> {\n    fn poll_read(\n        mut self: Pin<&mut Self>,\n        cx: &mut Context<'_>,\n        buf: &mut ReadBuf<'_>,\n    ) -> Poll<std::io::Result<()>> {\n        let before = buf.filled().len();\n        let result = Pin::new(&mut self.inner).poll_read(cx, buf);\n        if let Poll::Ready(Ok(())) = &result {\n            let bytes_read = buf.filled().len() - before;\n            if bytes_read > 0 {\n                // Ignore send errors - receiver may have been dropped or channel is full\n                let _ =\n                    self.event_tx.try_send(HttpResponseEvent::ChunkReceived { bytes: bytes_read });\n            } else if !self.ended {\n                self.ended = true;\n            }\n        }\n        result\n    }\n}\n\n/// Type alias for the body stream\ntype BodyStream = Pin<Box<dyn AsyncRead + Send>>;\n\n/// HTTP response with deferred body consumption.\n/// Headers are available immediately after send(), body can be consumed in different ways.\n/// Note: Debug is manually implemented since BodyStream doesn't implement Debug.\npub struct HttpResponse {\n    /// HTTP status code\n    pub status: u16,\n    /// HTTP status reason phrase (e.g., \"OK\", \"Not Found\")\n    pub status_reason: Option<String>,\n    /// Response headers (Vec to support multiple headers with same name, e.g., Set-Cookie)\n    pub headers: Vec<(String, String)>,\n    /// Request headers (Vec to support multiple headers with same name)\n    pub request_headers: Vec<(String, String)>,\n    /// Content-Length from headers (may differ from actual body size)\n    pub content_length: Option<u64>,\n    /// Final URL (after redirects)\n    pub url: String,\n    /// Remote address of the server\n    pub remote_addr: Option<String>,\n    /// HTTP version (e.g., \"HTTP/1.1\", \"HTTP/2\")\n    pub version: Option<String>,\n\n    /// The body stream (consumed when calling bytes(), text(), write_to_file(), or drain())\n    body_stream: Option<BodyStream>,\n    /// Content-Encoding for decompression\n    encoding: ContentEncoding,\n}\n\nimpl std::fmt::Debug for HttpResponse {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"HttpResponse\")\n            .field(\"status\", &self.status)\n            .field(\"status_reason\", &self.status_reason)\n            .field(\"headers\", &self.headers)\n            .field(\"content_length\", &self.content_length)\n            .field(\"url\", &self.url)\n            .field(\"remote_addr\", &self.remote_addr)\n            .field(\"version\", &self.version)\n            .field(\"body_stream\", &\"<stream>\")\n            .field(\"encoding\", &self.encoding)\n            .finish()\n    }\n}\n\nimpl HttpResponse {\n    /// Create a new HttpResponse with an unconsumed body stream\n    #[allow(clippy::too_many_arguments)]\n    pub fn new(\n        status: u16,\n        status_reason: Option<String>,\n        headers: Vec<(String, String)>,\n        request_headers: Vec<(String, String)>,\n        content_length: Option<u64>,\n        url: String,\n        remote_addr: Option<String>,\n        version: Option<String>,\n        body_stream: BodyStream,\n        encoding: ContentEncoding,\n    ) -> Self {\n        Self {\n            status,\n            status_reason,\n            headers,\n            request_headers,\n            content_length,\n            url,\n            remote_addr,\n            version,\n            body_stream: Some(body_stream),\n            encoding,\n        }\n    }\n\n    /// Consume the body and return it as bytes (loads entire body into memory).\n    /// Also decompresses the body if Content-Encoding is set.\n    pub async fn bytes(mut self) -> Result<(Vec<u8>, BodyStats)> {\n        let stream = self.body_stream.take().ok_or_else(|| {\n            Error::RequestError(\"Response body has already been consumed\".to_string())\n        })?;\n\n        let buf_reader = BufReader::new(stream);\n        let mut decoder = streaming_decoder(buf_reader, self.encoding);\n\n        let mut decompressed = Vec::new();\n        let mut bytes_read = 0u64;\n\n        // Read through the decoder in chunks to track compressed size\n        let mut buf = [0u8; 8192];\n        loop {\n            match decoder.read(&mut buf).await {\n                Ok(0) => break,\n                Ok(n) => {\n                    decompressed.extend_from_slice(&buf[..n]);\n                    bytes_read += n as u64;\n                }\n                Err(e) => {\n                    return Err(Error::BodyReadError(e.to_string()));\n                }\n            }\n        }\n\n        let stats = BodyStats {\n            // For now, we can't easily track compressed size when streaming through decoder\n            // Use content_length as an approximation, or decompressed size if identity encoding\n            size_compressed: self.content_length.unwrap_or(bytes_read),\n            size_decompressed: decompressed.len() as u64,\n        };\n\n        Ok((decompressed, stats))\n    }\n\n    /// Consume the body and return it as a UTF-8 string.\n    pub async fn text(self) -> Result<(String, BodyStats)> {\n        let (bytes, stats) = self.bytes().await?;\n        let text = String::from_utf8(bytes)\n            .map_err(|e| Error::RequestError(format!(\"Response is not valid UTF-8: {}\", e)))?;\n        Ok((text, stats))\n    }\n\n    /// Take the body stream for manual consumption.\n    /// Returns an AsyncRead that decompresses on-the-fly if Content-Encoding is set.\n    /// The caller is responsible for reading and processing the stream.\n    pub fn into_body_stream(&mut self) -> Result<Box<dyn AsyncRead + Unpin + Send>> {\n        let stream = self.body_stream.take().ok_or_else(|| {\n            Error::RequestError(\"Response body has already been consumed\".to_string())\n        })?;\n\n        let buf_reader = BufReader::new(stream);\n        let decoder = streaming_decoder(buf_reader, self.encoding);\n\n        Ok(decoder)\n    }\n\n    /// Discard the body without reading it (useful for redirects).\n    pub async fn drain(mut self) -> Result<()> {\n        let stream = self.body_stream.take().ok_or_else(|| {\n            Error::RequestError(\"Response body has already been consumed\".to_string())\n        })?;\n\n        // Just read and discard all bytes\n        let mut reader = stream;\n        let mut buf = [0u8; 8192];\n        loop {\n            match reader.read(&mut buf).await {\n                Ok(0) => break,\n                Ok(_) => continue,\n                Err(e) => {\n                    return Err(Error::RequestError(format!(\n                        \"Failed to drain response body: {}\",\n                        e\n                    )));\n                }\n            }\n        }\n\n        Ok(())\n    }\n}\n\n/// Trait for sending HTTP requests\n#[async_trait]\npub trait HttpSender: Send + Sync {\n    /// Send an HTTP request and return the response with headers.\n    /// The body is not consumed until you call bytes(), text(), write_to_file(), or drain().\n    /// Events are sent through the provided channel.\n    async fn send(\n        &self,\n        request: SendableHttpRequest,\n        event_tx: mpsc::Sender<HttpResponseEvent>,\n    ) -> Result<HttpResponse>;\n}\n\n/// Reqwest-based implementation of HttpSender\npub struct ReqwestSender {\n    client: Client,\n}\n\nimpl ReqwestSender {\n    /// Create a new ReqwestSender with a default client\n    pub fn new() -> Result<Self> {\n        let client = Client::builder().build().map_err(Error::Client)?;\n        Ok(Self { client })\n    }\n\n    /// Create a new ReqwestSender with a custom client\n    pub fn with_client(client: Client) -> Self {\n        Self { client }\n    }\n}\n\n#[async_trait]\nimpl HttpSender for ReqwestSender {\n    async fn send(\n        &self,\n        request: SendableHttpRequest,\n        event_tx: mpsc::Sender<HttpResponseEvent>,\n    ) -> Result<HttpResponse> {\n        // Helper to send events (ignores errors if receiver is dropped or channel is full)\n        let send_event = |event: HttpResponseEvent| {\n            let _ = event_tx.try_send(event);\n        };\n\n        // Parse the HTTP method\n        let method = Method::from_bytes(request.method.as_bytes())\n            .map_err(|e| Error::RequestError(format!(\"Invalid HTTP method: {}\", e)))?;\n\n        // Build the request\n        let mut req_builder = self.client.request(method, &request.url);\n\n        // Add headers\n        for header in request.headers {\n            if header.0.is_empty() {\n                continue;\n            }\n            req_builder = req_builder.header(&header.0, &header.1);\n        }\n\n        // Configure timeout\n        if let Some(d) = request.options.timeout\n            && !d.is_zero()\n        {\n            req_builder = req_builder.timeout(d);\n        }\n\n        // Add body\n        match request.body {\n            None => {}\n            Some(SendableBody::Bytes(bytes)) => {\n                req_builder = req_builder.body(bytes);\n            }\n            Some(SendableBody::Stream { data, content_length }) => {\n                // Convert AsyncRead stream to reqwest Body. If content length is\n                // known, wrap with a SizedBody so hyper can set Content-Length\n                // automatically (for both HTTP/1.1 and HTTP/2).\n                let stream = tokio_util::io::ReaderStream::new(data);\n                let body = if let Some(len) = content_length {\n                    reqwest::Body::wrap(SizedBody::new(stream, len))\n                } else {\n                    reqwest::Body::wrap_stream(stream)\n                };\n                req_builder = req_builder.body(body);\n            }\n        }\n\n        // Send the request\n        let sendable_req = req_builder.build()?;\n        send_event(HttpResponseEvent::Setting(\n            \"timeout\".to_string(),\n            if request.options.timeout.unwrap_or_default().is_zero() {\n                \"Infinity\".to_string()\n            } else {\n                format!(\"{:?}\", request.options.timeout)\n            },\n        ));\n\n        send_event(HttpResponseEvent::SendUrl {\n            method: sendable_req.method().to_string(),\n            scheme: sendable_req.url().scheme().to_string(),\n            username: sendable_req.url().username().to_string(),\n            password: sendable_req.url().password().unwrap_or_default().to_string(),\n            host: sendable_req.url().host_str().unwrap_or_default().to_string(),\n            port: sendable_req.url().port_or_known_default().unwrap_or(0),\n            path: sendable_req.url().path().to_string(),\n            query: sendable_req.url().query().unwrap_or_default().to_string(),\n            fragment: sendable_req.url().fragment().unwrap_or_default().to_string(),\n        });\n\n        let mut request_headers = Vec::new();\n        for (name, value) in sendable_req.headers() {\n            let v = value.to_str().unwrap_or_default().to_string();\n            request_headers.push((name.to_string(), v.clone()));\n            send_event(HttpResponseEvent::HeaderUp(name.to_string(), v));\n        }\n        send_event(HttpResponseEvent::Info(\"Sending request to server\".to_string()));\n\n        // Map some errors to our own, so they look nicer\n        let response = self.client.execute(sendable_req).await.map_err(|e| {\n            if reqwest::Error::is_timeout(&e) {\n                Error::RequestTimeout(\n                    request.options.timeout.unwrap_or(Duration::from_secs(0)).clone(),\n                )\n            } else {\n                Error::Client(e)\n            }\n        })?;\n\n        let status = response.status().as_u16();\n        let status_reason = response.status().canonical_reason().map(|s| s.to_string());\n        let url = response.url().to_string();\n        let remote_addr = response.remote_addr().map(|a| a.to_string());\n        let version = Some(version_to_str(&response.version()));\n        let content_length = response.content_length();\n\n        send_event(HttpResponseEvent::ReceiveUrl {\n            version: response.version(),\n            status: response.status().to_string(),\n        });\n\n        // Extract headers (use Vec to preserve duplicates like Set-Cookie)\n        let mut headers = Vec::new();\n        for (key, value) in response.headers() {\n            if let Ok(v) = value.to_str() {\n                send_event(HttpResponseEvent::HeaderDown(key.to_string(), v.to_string()));\n                headers.push((key.to_string(), v.to_string()));\n            }\n        }\n\n        // Determine content encoding for decompression\n        // HTTP headers are case-insensitive, so we need to search for any casing\n        let encoding = ContentEncoding::from_header(\n            headers\n                .iter()\n                .find(|(k, _)| k.eq_ignore_ascii_case(\"content-encoding\"))\n                .map(|(_, v)| v.as_str()),\n        );\n\n        // Get the byte stream instead of loading into memory\n        let byte_stream = response.bytes_stream();\n\n        // Convert the stream to an AsyncRead\n        let stream_reader = StreamReader::new(\n            byte_stream.map(|result| result.map_err(|e| std::io::Error::other(e))),\n        );\n\n        // Wrap the stream with tracking to emit chunk received events via the same channel\n        let tracking_reader = TrackingRead::new(stream_reader, event_tx);\n        let body_stream: BodyStream = Box::pin(tracking_reader);\n\n        Ok(HttpResponse::new(\n            status,\n            status_reason,\n            headers,\n            request_headers,\n            content_length,\n            url,\n            remote_addr,\n            version,\n            body_stream,\n            encoding,\n        ))\n    }\n}\n\n/// A wrapper around a byte stream that reports a known content length via\n/// `size_hint()`. This lets hyper set the `Content-Length` header\n/// automatically based on the body size, without us having to add it as an\n/// explicit header — which can cause duplicate `Content-Length` headers and\n/// break HTTP/2.\nstruct SizedBody<S> {\n    stream: std::sync::Mutex<S>,\n    remaining: u64,\n}\n\nimpl<S> SizedBody<S> {\n    fn new(stream: S, content_length: u64) -> Self {\n        Self { stream: std::sync::Mutex::new(stream), remaining: content_length }\n    }\n}\n\nimpl<S> HttpBody for SizedBody<S>\nwhere\n    S: futures_util::Stream<Item = std::result::Result<Bytes, std::io::Error>>\n        + Send\n        + Unpin\n        + 'static,\n{\n    type Data = Bytes;\n    type Error = std::io::Error;\n\n    fn poll_frame(\n        self: Pin<&mut Self>,\n        cx: &mut Context<'_>,\n    ) -> Poll<Option<std::result::Result<Frame<Self::Data>, Self::Error>>> {\n        let this = self.get_mut();\n        let mut stream = this.stream.lock().unwrap();\n        match stream.poll_next_unpin(cx) {\n            Poll::Ready(Some(Ok(chunk))) => {\n                this.remaining = this.remaining.saturating_sub(chunk.len() as u64);\n                Poll::Ready(Some(Ok(Frame::data(chunk))))\n            }\n            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),\n            Poll::Ready(None) => Poll::Ready(None),\n            Poll::Pending => Poll::Pending,\n        }\n    }\n\n    fn size_hint(&self) -> SizeHint {\n        SizeHint::with_exact(self.remaining)\n    }\n}\n\nfn version_to_str(version: &Version) -> String {\n    match *version {\n        Version::HTTP_09 => \"HTTP/0.9\".to_string(),\n        Version::HTTP_10 => \"HTTP/1.0\".to_string(),\n        Version::HTTP_11 => \"HTTP/1.1\".to_string(),\n        Version::HTTP_2 => \"HTTP/2\".to_string(),\n        Version::HTTP_3 => \"HTTP/3\".to_string(),\n        _ => \"unknown\".to_string(),\n    }\n}\n"
  },
  {
    "path": "crates/yaak-http/src/tee_reader.rs",
    "content": "use std::io;\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\nuse tokio::io::{AsyncRead, ReadBuf};\nuse tokio::sync::mpsc;\n\n/// A reader that forwards all read data to a channel while also returning it to the caller.\n/// This allows capturing request body data as it's being sent.\n/// Uses an unbounded channel to ensure all data is captured without blocking the request.\npub struct TeeReader<R> {\n    inner: R,\n    tx: mpsc::UnboundedSender<Vec<u8>>,\n}\n\nimpl<R> TeeReader<R> {\n    pub fn new(inner: R, tx: mpsc::UnboundedSender<Vec<u8>>) -> Self {\n        Self { inner, tx }\n    }\n}\n\nimpl<R: AsyncRead + Unpin> AsyncRead for TeeReader<R> {\n    fn poll_read(\n        mut self: Pin<&mut Self>,\n        cx: &mut Context<'_>,\n        buf: &mut ReadBuf<'_>,\n    ) -> Poll<io::Result<()>> {\n        let before_len = buf.filled().len();\n\n        match Pin::new(&mut self.inner).poll_read(cx, buf) {\n            Poll::Ready(Ok(())) => {\n                let after_len = buf.filled().len();\n                if after_len > before_len {\n                    // Data was read, send a copy to the channel\n                    let data = buf.filled()[before_len..after_len].to_vec();\n                    // Send to unbounded channel - this never blocks\n                    // Ignore error if receiver is closed\n                    let _ = self.tx.send(data);\n                }\n                Poll::Ready(Ok(()))\n            }\n            Poll::Ready(Err(e)) => Poll::Ready(Err(e)),\n            Poll::Pending => Poll::Pending,\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::io::Cursor;\n    use tokio::io::AsyncReadExt;\n\n    #[tokio::test]\n    async fn test_tee_reader_captures_all_data() {\n        let data = b\"Hello, World!\";\n        let cursor = Cursor::new(data.to_vec());\n        let (tx, mut rx) = mpsc::unbounded_channel();\n\n        let mut tee = TeeReader::new(cursor, tx);\n        let mut output = Vec::new();\n        tee.read_to_end(&mut output).await.unwrap();\n\n        // Verify the reader returns the correct data\n        assert_eq!(output, data);\n\n        // Verify the channel received the data\n        let mut captured = Vec::new();\n        while let Ok(chunk) = rx.try_recv() {\n            captured.extend(chunk);\n        }\n        assert_eq!(captured, data);\n    }\n\n    #[tokio::test]\n    async fn test_tee_reader_with_chunked_reads() {\n        let data = b\"ABCDEFGHIJKLMNOPQRSTUVWXYZ\";\n        let cursor = Cursor::new(data.to_vec());\n        let (tx, mut rx) = mpsc::unbounded_channel();\n\n        let mut tee = TeeReader::new(cursor, tx);\n\n        // Read in small chunks\n        let mut buf = [0u8; 5];\n        let mut output = Vec::new();\n        loop {\n            let n = tee.read(&mut buf).await.unwrap();\n            if n == 0 {\n                break;\n            }\n            output.extend_from_slice(&buf[..n]);\n        }\n\n        // Verify the reader returns the correct data\n        assert_eq!(output, data);\n\n        // Verify the channel received all chunks\n        let mut captured = Vec::new();\n        while let Ok(chunk) = rx.try_recv() {\n            captured.extend(chunk);\n        }\n        assert_eq!(captured, data);\n    }\n\n    #[tokio::test]\n    async fn test_tee_reader_empty_data() {\n        let data: Vec<u8> = vec![];\n        let cursor = Cursor::new(data.clone());\n        let (tx, mut rx) = mpsc::unbounded_channel();\n\n        let mut tee = TeeReader::new(cursor, tx);\n        let mut output = Vec::new();\n        tee.read_to_end(&mut output).await.unwrap();\n\n        // Verify empty output\n        assert!(output.is_empty());\n\n        // Verify no data was sent to channel\n        assert!(rx.try_recv().is_err());\n    }\n\n    #[tokio::test]\n    async fn test_tee_reader_works_when_receiver_dropped() {\n        let data = b\"Hello, World!\";\n        let cursor = Cursor::new(data.to_vec());\n        let (tx, rx) = mpsc::unbounded_channel();\n\n        // Drop the receiver before reading\n        drop(rx);\n\n        let mut tee = TeeReader::new(cursor, tx);\n        let mut output = Vec::new();\n\n        // Should still work even though receiver is dropped\n        tee.read_to_end(&mut output).await.unwrap();\n        assert_eq!(output, data);\n    }\n\n    #[tokio::test]\n    async fn test_tee_reader_large_data() {\n        // Test with 1MB of data\n        let data: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();\n        let cursor = Cursor::new(data.clone());\n        let (tx, mut rx) = mpsc::unbounded_channel();\n\n        let mut tee = TeeReader::new(cursor, tx);\n        let mut output = Vec::new();\n        tee.read_to_end(&mut output).await.unwrap();\n\n        // Verify the reader returns the correct data\n        assert_eq!(output, data);\n\n        // Verify the channel received all data\n        let mut captured = Vec::new();\n        while let Ok(chunk) = rx.try_recv() {\n            captured.extend(chunk);\n        }\n        assert_eq!(captured, data);\n    }\n}\n"
  },
  {
    "path": "crates/yaak-http/src/transaction.rs",
    "content": "use crate::cookies::CookieStore;\nuse crate::error::Result;\nuse crate::sender::{HttpResponse, HttpResponseEvent, HttpSender, RedirectBehavior};\nuse crate::types::{SendableBody, SendableHttpRequest};\nuse log::debug;\nuse tokio::sync::mpsc;\nuse tokio::sync::watch::Receiver;\nuse url::Url;\n\n/// HTTP Transaction that manages the lifecycle of a request, including redirect handling\npub struct HttpTransaction<S: HttpSender> {\n    sender: S,\n    max_redirects: usize,\n    cookie_store: Option<CookieStore>,\n}\n\nimpl<S: HttpSender> HttpTransaction<S> {\n    /// Create a new transaction with default settings\n    pub fn new(sender: S) -> Self {\n        Self { sender, max_redirects: 10, cookie_store: None }\n    }\n\n    /// Create a new transaction with custom max redirects\n    pub fn with_max_redirects(sender: S, max_redirects: usize) -> Self {\n        Self { sender, max_redirects, cookie_store: None }\n    }\n\n    /// Create a new transaction with a cookie store\n    pub fn with_cookie_store(sender: S, cookie_store: CookieStore) -> Self {\n        Self { sender, max_redirects: 10, cookie_store: Some(cookie_store) }\n    }\n\n    /// Create a new transaction with custom max redirects and a cookie store\n    pub fn with_options(\n        sender: S,\n        max_redirects: usize,\n        cookie_store: Option<CookieStore>,\n    ) -> Self {\n        Self { sender, max_redirects, cookie_store }\n    }\n\n    /// Execute the request with cancellation support.\n    /// Returns an HttpResponse with unconsumed body - caller decides how to consume it.\n    /// Events are sent through the provided channel.\n    pub async fn execute_with_cancellation(\n        &self,\n        request: SendableHttpRequest,\n        mut cancelled_rx: Receiver<bool>,\n        event_tx: mpsc::Sender<HttpResponseEvent>,\n    ) -> Result<HttpResponse> {\n        let mut redirect_count = 0;\n        let mut current_url = request.url;\n        let mut current_method = request.method;\n        let mut current_headers = request.headers;\n        let mut current_body = request.body;\n\n        // Helper to send events (ignores errors if receiver is dropped or channel is full)\n        let send_event = |event: HttpResponseEvent| {\n            let _ = event_tx.try_send(event);\n        };\n\n        loop {\n            // Check for cancellation before each request\n            if *cancelled_rx.borrow() {\n                return Err(crate::error::Error::RequestCanceledError);\n            }\n\n            // Inject cookies into headers if we have a cookie store\n            let headers_with_cookies = if let Some(cookie_store) = &self.cookie_store {\n                let mut headers = current_headers.clone();\n                if let Ok(url) = Url::parse(&current_url) {\n                    if let Some(cookie_header) = cookie_store.get_cookie_header(&url) {\n                        debug!(\"Injecting Cookie header: {}\", cookie_header);\n                        // Check if there's already a Cookie header and merge if so\n                        if let Some(existing) =\n                            headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case(\"cookie\"))\n                        {\n                            existing.1 = format!(\"{}; {}\", existing.1, cookie_header);\n                        } else {\n                            headers.push((\"Cookie\".to_string(), cookie_header));\n                        }\n                    }\n                }\n                headers\n            } else {\n                current_headers.clone()\n            };\n\n            // Build request for this iteration\n            let preserved_body = match &current_body {\n                Some(SendableBody::Bytes(b)) => Some(SendableBody::Bytes(b.clone())),\n                _ => None,\n            };\n            let request_had_body = current_body.is_some();\n            let req = SendableHttpRequest {\n                url: current_url.clone(),\n                method: current_method.clone(),\n                headers: headers_with_cookies,\n                body: current_body,\n                options: request.options.clone(),\n            };\n\n            // Send the request\n            send_event(HttpResponseEvent::Setting(\n                \"redirects\".to_string(),\n                request.options.follow_redirects.to_string(),\n            ));\n\n            // Execute with cancellation support\n            let response = tokio::select! {\n                result = self.sender.send(req, event_tx.clone()) => result?,\n                _ = cancelled_rx.changed() => {\n                    return Err(crate::error::Error::RequestCanceledError);\n                }\n            };\n\n            // Parse Set-Cookie headers and store cookies\n            if let Some(cookie_store) = &self.cookie_store {\n                if let Ok(url) = Url::parse(&current_url) {\n                    let set_cookie_headers: Vec<String> = response\n                        .headers\n                        .iter()\n                        .filter(|(k, _)| k.eq_ignore_ascii_case(\"set-cookie\"))\n                        .map(|(_, v)| v.clone())\n                        .collect();\n\n                    if !set_cookie_headers.is_empty() {\n                        debug!(\"Storing {} cookies from response\", set_cookie_headers.len());\n                        cookie_store.store_cookies_from_response(&url, &set_cookie_headers);\n                    }\n                }\n            }\n\n            if !Self::is_redirect(response.status) {\n                // Not a redirect - return the response for caller to consume body\n                return Ok(response);\n            }\n\n            if !request.options.follow_redirects {\n                // Redirects disabled - return the redirect response as-is\n                return Ok(response);\n            }\n\n            // Check if we've exceeded max redirects\n            if redirect_count >= self.max_redirects {\n                // Drain the response before returning error\n                let _ = response.drain().await;\n                return Err(crate::error::Error::RequestError(format!(\n                    \"Maximum redirect limit ({}) exceeded\",\n                    self.max_redirects\n                )));\n            }\n\n            // Extract Location header before draining (headers are available immediately)\n            // HTTP headers are case-insensitive, so we need to search for any casing\n            let location = response\n                .headers\n                .iter()\n                .find(|(k, _)| k.eq_ignore_ascii_case(\"location\"))\n                .map(|(_, v)| v.clone())\n                .ok_or_else(|| {\n                    crate::error::Error::RequestError(\n                        \"Redirect response missing Location header\".to_string(),\n                    )\n                })?;\n\n            // Also get status before draining\n            let status = response.status;\n\n            send_event(HttpResponseEvent::Info(\"Ignoring the response body\".to_string()));\n\n            // Drain the redirect response body before following\n            response.drain().await?;\n\n            // Update the request URL\n            let previous_url = current_url.clone();\n            current_url = if location.starts_with(\"http://\") || location.starts_with(\"https://\") {\n                // Absolute URL\n                location\n            } else if location.starts_with('/') {\n                // Absolute path - need to extract base URL from current request\n                let base_url = Self::extract_base_url(&current_url)?;\n                format!(\"{}{}\", base_url, location)\n            } else {\n                // Relative path - need to resolve relative to current path\n                let base_path = Self::extract_base_path(&current_url)?;\n                format!(\"{}/{}\", base_path, location)\n            };\n\n            // Determine redirect behavior based on status code and method\n            let behavior = if status == 303 {\n                // 303 See Other always changes to GET\n                RedirectBehavior::DropBody\n            } else if (status == 301 || status == 302) && current_method == \"POST\" {\n                // For 301/302, change POST to GET (common browser behavior)\n                RedirectBehavior::DropBody\n            } else {\n                // For 307 and 308, the method and body are preserved\n                // Also for 301/302 with non-POST methods\n                RedirectBehavior::Preserve\n            };\n\n            let mut dropped_headers =\n                Self::remove_sensitive_headers(&mut current_headers, &previous_url, &current_url);\n\n            // Handle method changes for certain redirect codes\n            if matches!(behavior, RedirectBehavior::DropBody) {\n                if current_method != \"GET\" {\n                    current_method = \"GET\".to_string();\n                }\n                // Remove content-related headers\n                current_headers.retain(|h| {\n                    let name_lower = h.0.to_lowercase();\n                    let should_drop =\n                        name_lower.starts_with(\"content-\") || name_lower == \"transfer-encoding\";\n                    if should_drop {\n                        Self::push_header_if_missing(&mut dropped_headers, &h.0);\n                    }\n                    !should_drop\n                });\n            }\n\n            // Restore body for Preserve redirects (307/308), drop for others.\n            // Stream bodies can't be replayed (same limitation as reqwest).\n            current_body = if matches!(behavior, RedirectBehavior::Preserve) {\n                if request_had_body && preserved_body.is_none() {\n                    // Stream body was consumed and can't be replayed (same as reqwest)\n                    return Err(crate::error::Error::RequestError(\n                        \"Cannot follow redirect: request body was a stream and cannot be resent\"\n                            .to_string(),\n                    ));\n                }\n                preserved_body\n            } else {\n                None\n            };\n\n            // Body was dropped if the request had one but we can't resend it\n            let dropped_body = request_had_body && current_body.is_none();\n\n            send_event(HttpResponseEvent::Redirect {\n                url: current_url.clone(),\n                status,\n                behavior: behavior.clone(),\n                dropped_body,\n                dropped_headers,\n            });\n\n            redirect_count += 1;\n        }\n    }\n\n    /// Remove sensitive headers when redirecting to a different host.\n    /// This matches reqwest's `remove_sensitive_headers()` behavior and prevents\n    /// credentials from being forwarded to third-party servers (e.g., an\n    /// Authorization header sent from an API redirect to an S3 bucket).\n    fn remove_sensitive_headers(\n        headers: &mut Vec<(String, String)>,\n        previous_url: &str,\n        next_url: &str,\n    ) -> Vec<String> {\n        let mut dropped_headers = Vec::new();\n        let previous_host = Url::parse(previous_url).ok().and_then(|u| {\n            u.host_str().map(|h| format!(\"{}:{}\", h, u.port_or_known_default().unwrap_or(0)))\n        });\n        let next_host = Url::parse(next_url).ok().and_then(|u| {\n            u.host_str().map(|h| format!(\"{}:{}\", h, u.port_or_known_default().unwrap_or(0)))\n        });\n        if previous_host != next_host {\n            headers.retain(|h| {\n                let name_lower = h.0.to_lowercase();\n                let should_drop = name_lower == \"authorization\"\n                    || name_lower == \"cookie\"\n                    || name_lower == \"cookie2\"\n                    || name_lower == \"proxy-authorization\"\n                    || name_lower == \"www-authenticate\";\n                if should_drop {\n                    Self::push_header_if_missing(&mut dropped_headers, &h.0);\n                }\n                !should_drop\n            });\n        }\n        dropped_headers\n    }\n\n    fn push_header_if_missing(headers: &mut Vec<String>, name: &str) {\n        if !headers.iter().any(|h| h.eq_ignore_ascii_case(name)) {\n            headers.push(name.to_string());\n        }\n    }\n\n    /// Check if a status code indicates a redirect\n    fn is_redirect(status: u16) -> bool {\n        matches!(status, 301 | 302 | 303 | 307 | 308)\n    }\n\n    /// Extract the base URL (scheme + host) from a full URL\n    fn extract_base_url(url: &str) -> Result<String> {\n        // Find the position after \"://\"\n        let scheme_end = url.find(\"://\").ok_or_else(|| {\n            crate::error::Error::RequestError(format!(\"Invalid URL format: {}\", url))\n        })?;\n\n        // Find the first '/' after the scheme\n        let path_start = url[scheme_end + 3..].find('/');\n\n        if let Some(idx) = path_start {\n            Ok(url[..scheme_end + 3 + idx].to_string())\n        } else {\n            // No path, return entire URL\n            Ok(url.to_string())\n        }\n    }\n\n    /// Extract the base path (everything except the last segment) from a URL\n    fn extract_base_path(url: &str) -> Result<String> {\n        if let Some(last_slash) = url.rfind('/') {\n            // Don't include the trailing slash if it's part of the host\n            if url[..last_slash].ends_with(\"://\") || url[..last_slash].ends_with(':') {\n                Ok(url.to_string())\n            } else {\n                Ok(url[..last_slash].to_string())\n            }\n        } else {\n            Ok(url.to_string())\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::decompress::ContentEncoding;\n    use crate::sender::{HttpResponseEvent, HttpSender};\n    use async_trait::async_trait;\n    use std::pin::Pin;\n    use std::sync::Arc;\n    use tokio::io::AsyncRead;\n    use tokio::sync::Mutex;\n\n    /// Captured request metadata for test assertions\n    #[derive(Debug, Clone)]\n    #[allow(dead_code)]\n    struct CapturedRequest {\n        url: String,\n        method: String,\n        headers: Vec<(String, String)>,\n    }\n\n    /// Mock sender for testing\n    struct MockSender {\n        responses: Arc<Mutex<Vec<MockResponse>>>,\n        /// Captured requests for assertions\n        captured_requests: Arc<Mutex<Vec<CapturedRequest>>>,\n    }\n\n    struct MockResponse {\n        status: u16,\n        headers: Vec<(String, String)>,\n        body: Vec<u8>,\n    }\n\n    impl MockSender {\n        fn new(responses: Vec<MockResponse>) -> Self {\n            Self {\n                responses: Arc::new(Mutex::new(responses)),\n                captured_requests: Arc::new(Mutex::new(Vec::new())),\n            }\n        }\n    }\n\n    #[async_trait]\n    impl HttpSender for MockSender {\n        async fn send(\n            &self,\n            request: SendableHttpRequest,\n            _event_tx: mpsc::Sender<HttpResponseEvent>,\n        ) -> Result<HttpResponse> {\n            // Capture the request metadata for later assertions\n            self.captured_requests.lock().await.push(CapturedRequest {\n                url: request.url.clone(),\n                method: request.method.clone(),\n                headers: request.headers.clone(),\n            });\n\n            let mut responses = self.responses.lock().await;\n            if responses.is_empty() {\n                Err(crate::error::Error::RequestError(\"No more mock responses\".to_string()))\n            } else {\n                let mock = responses.remove(0);\n                // Create a simple in-memory stream from the body\n                let body_stream: Pin<Box<dyn AsyncRead + Send>> =\n                    Box::pin(std::io::Cursor::new(mock.body));\n                Ok(HttpResponse::new(\n                    mock.status,\n                    None, // status_reason\n                    mock.headers,\n                    Vec::new(),\n                    None,                              // content_length\n                    \"https://example.com\".to_string(), // url\n                    None,                              // remote_addr\n                    Some(\"HTTP/1.1\".to_string()),      // version\n                    body_stream,\n                    ContentEncoding::Identity,\n                ))\n            }\n        }\n    }\n\n    #[tokio::test]\n    async fn test_transaction_no_redirect() {\n        let response = MockResponse { status: 200, headers: Vec::new(), body: b\"OK\".to_vec() };\n        let sender = MockSender::new(vec![response]);\n        let transaction = HttpTransaction::new(sender);\n\n        let request = SendableHttpRequest {\n            url: \"https://example.com\".to_string(),\n            method: \"GET\".to_string(),\n            headers: vec![],\n            ..Default::default()\n        };\n\n        let (_tx, rx) = tokio::sync::watch::channel(false);\n        let (event_tx, _event_rx) = mpsc::channel(100);\n        let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();\n        assert_eq!(result.status, 200);\n\n        // Consume the body to verify it\n        let (body, _) = result.bytes().await.unwrap();\n        assert_eq!(body, b\"OK\");\n    }\n\n    #[tokio::test]\n    async fn test_transaction_single_redirect() {\n        let redirect_headers =\n            vec![(\"Location\".to_string(), \"https://example.com/new\".to_string())];\n\n        let responses = vec![\n            MockResponse { status: 302, headers: redirect_headers, body: vec![] },\n            MockResponse { status: 200, headers: Vec::new(), body: b\"Final\".to_vec() },\n        ];\n\n        let sender = MockSender::new(responses);\n        let transaction = HttpTransaction::new(sender);\n\n        let request = SendableHttpRequest {\n            url: \"https://example.com/old\".to_string(),\n            method: \"GET\".to_string(),\n            options: crate::types::SendableHttpRequestOptions {\n                follow_redirects: true,\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n\n        let (_tx, rx) = tokio::sync::watch::channel(false);\n        let (event_tx, _event_rx) = mpsc::channel(100);\n        let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();\n        assert_eq!(result.status, 200);\n\n        let (body, _) = result.bytes().await.unwrap();\n        assert_eq!(body, b\"Final\");\n    }\n\n    #[tokio::test]\n    async fn test_transaction_max_redirects_exceeded() {\n        let redirect_headers =\n            vec![(\"Location\".to_string(), \"https://example.com/loop\".to_string())];\n\n        // Create more redirects than allowed\n        let responses: Vec<MockResponse> = (0..12)\n            .map(|_| MockResponse { status: 302, headers: redirect_headers.clone(), body: vec![] })\n            .collect();\n\n        let sender = MockSender::new(responses);\n        let transaction = HttpTransaction::with_max_redirects(sender, 10);\n\n        let request = SendableHttpRequest {\n            url: \"https://example.com/start\".to_string(),\n            method: \"GET\".to_string(),\n            options: crate::types::SendableHttpRequestOptions {\n                follow_redirects: true,\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n\n        let (_tx, rx) = tokio::sync::watch::channel(false);\n        let (event_tx, _event_rx) = mpsc::channel(100);\n        let result = transaction.execute_with_cancellation(request, rx, event_tx).await;\n        if let Err(crate::error::Error::RequestError(msg)) = result {\n            assert!(msg.contains(\"Maximum redirect limit\"));\n        } else {\n            panic!(\"Expected RequestError with max redirect message. Got {result:?}\");\n        }\n    }\n\n    #[test]\n    fn test_is_redirect() {\n        assert!(HttpTransaction::<MockSender>::is_redirect(301));\n        assert!(HttpTransaction::<MockSender>::is_redirect(302));\n        assert!(HttpTransaction::<MockSender>::is_redirect(303));\n        assert!(HttpTransaction::<MockSender>::is_redirect(307));\n        assert!(HttpTransaction::<MockSender>::is_redirect(308));\n        assert!(!HttpTransaction::<MockSender>::is_redirect(200));\n        assert!(!HttpTransaction::<MockSender>::is_redirect(404));\n        assert!(!HttpTransaction::<MockSender>::is_redirect(500));\n    }\n\n    #[test]\n    fn test_extract_base_url() {\n        let result =\n            HttpTransaction::<MockSender>::extract_base_url(\"https://example.com/path/to/resource\");\n        assert_eq!(result.unwrap(), \"https://example.com\");\n\n        let result = HttpTransaction::<MockSender>::extract_base_url(\"http://localhost:8080/api\");\n        assert_eq!(result.unwrap(), \"http://localhost:8080\");\n\n        let result = HttpTransaction::<MockSender>::extract_base_url(\"invalid-url\");\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn test_extract_base_path() {\n        let result = HttpTransaction::<MockSender>::extract_base_path(\n            \"https://example.com/path/to/resource\",\n        );\n        assert_eq!(result.unwrap(), \"https://example.com/path/to\");\n\n        let result = HttpTransaction::<MockSender>::extract_base_path(\"https://example.com/single\");\n        assert_eq!(result.unwrap(), \"https://example.com\");\n\n        let result = HttpTransaction::<MockSender>::extract_base_path(\"https://example.com/\");\n        assert_eq!(result.unwrap(), \"https://example.com\");\n    }\n\n    #[tokio::test]\n    async fn test_cookie_injection() {\n        // Create a mock sender that verifies the Cookie header was injected\n        struct CookieVerifyingSender {\n            expected_cookie: String,\n        }\n\n        #[async_trait]\n        impl HttpSender for CookieVerifyingSender {\n            async fn send(\n                &self,\n                request: SendableHttpRequest,\n                _event_tx: mpsc::Sender<HttpResponseEvent>,\n            ) -> Result<HttpResponse> {\n                // Verify the Cookie header was injected\n                let cookie_header =\n                    request.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(\"cookie\"));\n\n                assert!(cookie_header.is_some(), \"Cookie header should be present\");\n                assert!(\n                    cookie_header.unwrap().1.contains(&self.expected_cookie),\n                    \"Cookie header should contain expected value\"\n                );\n\n                let body_stream: Pin<Box<dyn AsyncRead + Send>> =\n                    Box::pin(std::io::Cursor::new(vec![]));\n                Ok(HttpResponse::new(\n                    200,\n                    None,\n                    Vec::new(),\n                    Vec::new(),\n                    None,\n                    \"https://example.com\".to_string(),\n                    None,\n                    Some(\"HTTP/1.1\".to_string()),\n                    body_stream,\n                    ContentEncoding::Identity,\n                ))\n            }\n        }\n\n        use yaak_models::models::{Cookie, CookieDomain, CookieExpires};\n\n        // Create a cookie store with a test cookie\n        let cookie = Cookie {\n            raw_cookie: \"session=abc123\".to_string(),\n            domain: CookieDomain::HostOnly(\"example.com\".to_string()),\n            expires: CookieExpires::SessionEnd,\n            path: (\"/\".to_string(), false),\n        };\n        let cookie_store = CookieStore::from_cookies(vec![cookie]);\n\n        let sender = CookieVerifyingSender { expected_cookie: \"session=abc123\".to_string() };\n        let transaction = HttpTransaction::with_cookie_store(sender, cookie_store);\n\n        let request = SendableHttpRequest {\n            url: \"https://example.com/api\".to_string(),\n            method: \"GET\".to_string(),\n            headers: vec![],\n            ..Default::default()\n        };\n\n        let (_tx, rx) = tokio::sync::watch::channel(false);\n        let (event_tx, _event_rx) = mpsc::channel(100);\n        let result = transaction.execute_with_cancellation(request, rx, event_tx).await;\n        assert!(result.is_ok());\n    }\n\n    #[tokio::test]\n    async fn test_set_cookie_parsing() {\n        // Create a cookie store\n        let cookie_store = CookieStore::new();\n\n        // Mock sender that returns a Set-Cookie header\n        struct SetCookieSender;\n\n        #[async_trait]\n        impl HttpSender for SetCookieSender {\n            async fn send(\n                &self,\n                _request: SendableHttpRequest,\n                _event_tx: mpsc::Sender<HttpResponseEvent>,\n            ) -> Result<HttpResponse> {\n                let headers =\n                    vec![(\"set-cookie\".to_string(), \"session=xyz789; Path=/\".to_string())];\n\n                let body_stream: Pin<Box<dyn AsyncRead + Send>> =\n                    Box::pin(std::io::Cursor::new(vec![]));\n                Ok(HttpResponse::new(\n                    200,\n                    None,\n                    headers,\n                    Vec::new(),\n                    None,\n                    \"https://example.com\".to_string(),\n                    None,\n                    Some(\"HTTP/1.1\".to_string()),\n                    body_stream,\n                    ContentEncoding::Identity,\n                ))\n            }\n        }\n\n        let sender = SetCookieSender;\n        let transaction = HttpTransaction::with_cookie_store(sender, cookie_store.clone());\n\n        let request = SendableHttpRequest {\n            url: \"https://example.com/login\".to_string(),\n            method: \"POST\".to_string(),\n            headers: vec![],\n            ..Default::default()\n        };\n\n        let (_tx, rx) = tokio::sync::watch::channel(false);\n        let (event_tx, _event_rx) = mpsc::channel(100);\n        let result = transaction.execute_with_cancellation(request, rx, event_tx).await;\n        assert!(result.is_ok());\n\n        // Verify the cookie was stored\n        let cookies = cookie_store.get_all_cookies();\n        assert_eq!(cookies.len(), 1);\n        assert!(cookies[0].raw_cookie.contains(\"session=xyz789\"));\n    }\n\n    #[tokio::test]\n    async fn test_multiple_set_cookie_headers() {\n        // Create a cookie store\n        let cookie_store = CookieStore::new();\n\n        // Mock sender that returns multiple Set-Cookie headers\n        struct MultiSetCookieSender;\n\n        #[async_trait]\n        impl HttpSender for MultiSetCookieSender {\n            async fn send(\n                &self,\n                _request: SendableHttpRequest,\n                _event_tx: mpsc::Sender<HttpResponseEvent>,\n            ) -> Result<HttpResponse> {\n                // Multiple Set-Cookie headers (this is standard HTTP behavior)\n                let headers = vec![\n                    (\"set-cookie\".to_string(), \"session=abc123; Path=/\".to_string()),\n                    (\"set-cookie\".to_string(), \"user_id=42; Path=/\".to_string()),\n                    (\n                        \"set-cookie\".to_string(),\n                        \"preferences=dark; Path=/; Max-Age=86400\".to_string(),\n                    ),\n                ];\n\n                let body_stream: Pin<Box<dyn AsyncRead + Send>> =\n                    Box::pin(std::io::Cursor::new(vec![]));\n                Ok(HttpResponse::new(\n                    200,\n                    None,\n                    headers,\n                    Vec::new(),\n                    None,\n                    \"https://example.com\".to_string(),\n                    None,\n                    Some(\"HTTP/1.1\".to_string()),\n                    body_stream,\n                    ContentEncoding::Identity,\n                ))\n            }\n        }\n\n        let sender = MultiSetCookieSender;\n        let transaction = HttpTransaction::with_cookie_store(sender, cookie_store.clone());\n\n        let request = SendableHttpRequest {\n            url: \"https://example.com/login\".to_string(),\n            method: \"POST\".to_string(),\n            headers: vec![],\n            ..Default::default()\n        };\n\n        let (_tx, rx) = tokio::sync::watch::channel(false);\n        let (event_tx, _event_rx) = mpsc::channel(100);\n        let result = transaction.execute_with_cancellation(request, rx, event_tx).await;\n        assert!(result.is_ok());\n\n        // Verify all three cookies were stored\n        let cookies = cookie_store.get_all_cookies();\n        assert_eq!(cookies.len(), 3, \"All three Set-Cookie headers should be parsed and stored\");\n\n        let cookie_values: Vec<&str> = cookies.iter().map(|c| c.raw_cookie.as_str()).collect();\n        assert!(\n            cookie_values.iter().any(|c| c.contains(\"session=abc123\")),\n            \"session cookie should be stored\"\n        );\n        assert!(\n            cookie_values.iter().any(|c| c.contains(\"user_id=42\")),\n            \"user_id cookie should be stored\"\n        );\n        assert!(\n            cookie_values.iter().any(|c| c.contains(\"preferences=dark\")),\n            \"preferences cookie should be stored\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_cookies_across_redirects() {\n        use std::sync::atomic::{AtomicUsize, Ordering};\n\n        // Create a cookie store\n        let cookie_store = CookieStore::new();\n\n        // Track request count\n        let request_count = Arc::new(AtomicUsize::new(0));\n        let request_count_clone = request_count.clone();\n\n        struct RedirectWithCookiesSender {\n            request_count: Arc<AtomicUsize>,\n        }\n\n        #[async_trait]\n        impl HttpSender for RedirectWithCookiesSender {\n            async fn send(\n                &self,\n                request: SendableHttpRequest,\n                _event_tx: mpsc::Sender<HttpResponseEvent>,\n            ) -> Result<HttpResponse> {\n                let count = self.request_count.fetch_add(1, Ordering::SeqCst);\n\n                let (status, headers) = if count == 0 {\n                    // First request: return redirect with Set-Cookie\n                    let h = vec![\n                        (\"location\".to_string(), \"https://example.com/final\".to_string()),\n                        (\"set-cookie\".to_string(), \"redirect_cookie=value1\".to_string()),\n                    ];\n                    (302, h)\n                } else {\n                    // Second request: verify cookie was sent\n                    let cookie_header =\n                        request.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(\"cookie\"));\n\n                    assert!(cookie_header.is_some(), \"Cookie header should be present on redirect\");\n                    assert!(\n                        cookie_header.unwrap().1.contains(\"redirect_cookie=value1\"),\n                        \"Redirect cookie should be included\"\n                    );\n\n                    (200, Vec::new())\n                };\n\n                let body_stream: Pin<Box<dyn AsyncRead + Send>> =\n                    Box::pin(std::io::Cursor::new(vec![]));\n                Ok(HttpResponse::new(\n                    status,\n                    None,\n                    headers,\n                    Vec::new(),\n                    None,\n                    \"https://example.com\".to_string(),\n                    None,\n                    Some(\"HTTP/1.1\".to_string()),\n                    body_stream,\n                    ContentEncoding::Identity,\n                ))\n            }\n        }\n\n        let sender = RedirectWithCookiesSender { request_count: request_count_clone };\n        let transaction = HttpTransaction::with_cookie_store(sender, cookie_store);\n\n        let request = SendableHttpRequest {\n            url: \"https://example.com/start\".to_string(),\n            method: \"GET\".to_string(),\n            headers: vec![],\n            options: crate::types::SendableHttpRequestOptions {\n                follow_redirects: true,\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n\n        let (_tx, rx) = tokio::sync::watch::channel(false);\n        let (event_tx, _event_rx) = mpsc::channel(100);\n        let result = transaction.execute_with_cancellation(request, rx, event_tx).await;\n        assert!(result.is_ok());\n        assert_eq!(request_count.load(Ordering::SeqCst), 2);\n    }\n\n    #[tokio::test]\n    async fn test_cross_origin_redirect_strips_auth_headers() {\n        // Redirect from api.example.com -> s3.amazonaws.com should strip Authorization\n        let responses = vec![\n            MockResponse {\n                status: 302,\n                headers: vec![(\n                    \"Location\".to_string(),\n                    \"https://s3.amazonaws.com/bucket/file.pdf\".to_string(),\n                )],\n                body: vec![],\n            },\n            MockResponse { status: 200, headers: Vec::new(), body: b\"PDF content\".to_vec() },\n        ];\n\n        let sender = MockSender::new(responses);\n        let captured = sender.captured_requests.clone();\n        let transaction = HttpTransaction::new(sender);\n\n        let request = SendableHttpRequest {\n            url: \"https://api.example.com/download\".to_string(),\n            method: \"GET\".to_string(),\n            headers: vec![\n                (\"Authorization\".to_string(), \"Basic dXNlcjpwYXNz\".to_string()),\n                (\"Accept\".to_string(), \"application/pdf\".to_string()),\n            ],\n            options: crate::types::SendableHttpRequestOptions {\n                follow_redirects: true,\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n\n        let (_tx, rx) = tokio::sync::watch::channel(false);\n        let (event_tx, _event_rx) = mpsc::channel(100);\n        let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();\n        assert_eq!(result.status, 200);\n\n        let requests = captured.lock().await;\n        assert_eq!(requests.len(), 2);\n\n        // First request should have the Authorization header\n        assert!(\n            requests[0].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(\"authorization\")),\n            \"First request should have Authorization header\"\n        );\n\n        // Second request (to different host) should NOT have the Authorization header\n        assert!(\n            !requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(\"authorization\")),\n            \"Redirected request to different host should NOT have Authorization header\"\n        );\n\n        // Non-sensitive headers should still be present\n        assert!(\n            requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(\"accept\")),\n            \"Non-sensitive headers should be preserved across cross-origin redirects\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_same_origin_redirect_preserves_auth_headers() {\n        // Redirect within the same host should keep Authorization\n        let responses = vec![\n            MockResponse {\n                status: 302,\n                headers: vec![(\n                    \"Location\".to_string(),\n                    \"https://api.example.com/v2/download\".to_string(),\n                )],\n                body: vec![],\n            },\n            MockResponse { status: 200, headers: Vec::new(), body: b\"OK\".to_vec() },\n        ];\n\n        let sender = MockSender::new(responses);\n        let captured = sender.captured_requests.clone();\n        let transaction = HttpTransaction::new(sender);\n\n        let request = SendableHttpRequest {\n            url: \"https://api.example.com/v1/download\".to_string(),\n            method: \"GET\".to_string(),\n            headers: vec![\n                (\"Authorization\".to_string(), \"Bearer token123\".to_string()),\n                (\"Accept\".to_string(), \"application/json\".to_string()),\n            ],\n            options: crate::types::SendableHttpRequestOptions {\n                follow_redirects: true,\n                ..Default::default()\n            },\n            ..Default::default()\n        };\n\n        let (_tx, rx) = tokio::sync::watch::channel(false);\n        let (event_tx, _event_rx) = mpsc::channel(100);\n        let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();\n        assert_eq!(result.status, 200);\n\n        let requests = captured.lock().await;\n        assert_eq!(requests.len(), 2);\n\n        // Both requests should have the Authorization header (same host)\n        assert!(\n            requests[0].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(\"authorization\")),\n            \"First request should have Authorization header\"\n        );\n        assert!(\n            requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(\"authorization\")),\n            \"Redirected request to same host should preserve Authorization header\"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/yaak-http/src/types.rs",
    "content": "use crate::chained_reader::{ChainedReader, ReaderType};\nuse crate::error::Error::RequestError;\nuse crate::error::Result;\nuse crate::path_placeholders::apply_path_placeholders;\nuse crate::proto::ensure_proto;\nuse bytes::Bytes;\nuse log::warn;\nuse std::collections::BTreeMap;\nuse std::pin::Pin;\nuse std::time::Duration;\nuse tokio::io::AsyncRead;\nuse yaak_common::serde::{get_bool, get_bool_map, get_str, get_str_map};\nuse yaak_models::models::HttpRequest;\nuse yaak_templates::strip_json_comments::{maybe_strip_json_comments, strip_json_comments};\n\npub(crate) const MULTIPART_BOUNDARY: &str = \"------YaakFormBoundary\";\n\npub enum SendableBody {\n    Bytes(Bytes),\n    Stream {\n        data: Pin<Box<dyn AsyncRead + Send + 'static>>,\n        /// Known content length for the stream, if available. This is used by\n        /// the sender to set the body size hint so that hyper can set\n        /// Content-Length automatically for both HTTP/1.1 and HTTP/2.\n        content_length: Option<u64>,\n    },\n}\n\nenum SendableBodyWithMeta {\n    Bytes(Bytes),\n    Stream {\n        data: Pin<Box<dyn AsyncRead + Send + 'static>>,\n        content_length: Option<usize>,\n    },\n}\n\nimpl From<SendableBodyWithMeta> for SendableBody {\n    fn from(value: SendableBodyWithMeta) -> Self {\n        match value {\n            SendableBodyWithMeta::Bytes(b) => SendableBody::Bytes(b),\n            SendableBodyWithMeta::Stream { data, content_length } => {\n                SendableBody::Stream { data, content_length: content_length.map(|l| l as u64) }\n            }\n        }\n    }\n}\n\n#[derive(Default)]\npub struct SendableHttpRequest {\n    pub url: String,\n    pub method: String,\n    pub headers: Vec<(String, String)>,\n    pub body: Option<SendableBody>,\n    pub options: SendableHttpRequestOptions,\n}\n\n#[derive(Default, Clone)]\npub struct SendableHttpRequestOptions {\n    pub timeout: Option<Duration>,\n    pub follow_redirects: bool,\n}\n\nimpl SendableHttpRequest {\n    pub async fn from_http_request(\n        r: &HttpRequest,\n        options: SendableHttpRequestOptions,\n    ) -> Result<Self> {\n        let initial_headers = build_headers(r);\n        let (body, headers) = build_body(&r.method, &r.body_type, &r.body, initial_headers).await?;\n\n        Ok(Self {\n            url: build_url(r),\n            method: r.method.to_uppercase(),\n            headers,\n            body: body.into(),\n            options,\n        })\n    }\n\n    pub fn insert_header(&mut self, header: (String, String)) {\n        if let Some(existing) =\n            self.headers.iter_mut().find(|h| h.0.to_lowercase() == header.0.to_lowercase())\n        {\n            existing.1 = header.1;\n        } else {\n            self.headers.push(header);\n        }\n    }\n}\n\npub fn append_query_params(url: &str, params: Vec<(String, String)>) -> String {\n    let url_string = url.to_string();\n    if params.is_empty() {\n        return url.to_string();\n    }\n\n    // Build query string\n    let query_string = params\n        .iter()\n        .map(|(name, value)| {\n            format!(\"{}={}\", urlencoding::encode(name), urlencoding::encode(value))\n        })\n        .collect::<Vec<_>>()\n        .join(\"&\");\n\n    // Split URL into parts: base URL, query, and fragment\n    let (base_and_query, fragment) = if let Some(hash_pos) = url_string.find('#') {\n        let (before_hash, after_hash) = url_string.split_at(hash_pos);\n        (before_hash.to_string(), Some(after_hash.to_string()))\n    } else {\n        (url_string, None)\n    };\n\n    // Now handle query parameters on the base URL (without fragment)\n    let mut result = if base_and_query.contains('?') {\n        // Check if there's already a query string after the '?'\n        let parts: Vec<&str> = base_and_query.splitn(2, '?').collect();\n        if parts.len() == 2 && !parts[1].trim().is_empty() {\n            // Append with & if there are existing parameters\n            format!(\"{}&{}\", base_and_query, query_string)\n        } else {\n            // Just append the new parameters directly (URL ends with '?')\n            format!(\"{}{}\", base_and_query, query_string)\n        }\n    } else {\n        // No existing query parameters, add with '?'\n        format!(\"{}?{}\", base_and_query, query_string)\n    };\n\n    // Re-append the fragment if it exists\n    if let Some(fragment) = fragment {\n        result.push_str(&fragment);\n    }\n\n    result\n}\n\nfn strip_query_params(url: &str, names: &[&str]) -> String {\n    // Split off fragment\n    let (base_and_query, fragment) = if let Some(hash_pos) = url.find('#') {\n        (&url[..hash_pos], Some(&url[hash_pos..]))\n    } else {\n        (url, None)\n    };\n\n    let result = if let Some(q_pos) = base_and_query.find('?') {\n        let base = &base_and_query[..q_pos];\n        let query = &base_and_query[q_pos + 1..];\n        let filtered: Vec<&str> = query\n            .split('&')\n            .filter(|pair| {\n                let key = pair.split('=').next().unwrap_or(\"\");\n                let decoded = urlencoding::decode(key).unwrap_or_default();\n                !names.contains(&decoded.as_ref())\n            })\n            .collect();\n        if filtered.is_empty() {\n            base.to_string()\n        } else {\n            format!(\"{}?{}\", base, filtered.join(\"&\"))\n        }\n    } else {\n        base_and_query.to_string()\n    };\n\n    match fragment {\n        Some(f) => format!(\"{}{}\", result, f),\n        None => result,\n    }\n}\n\nfn build_url(r: &HttpRequest) -> String {\n    let (url_string, params) = apply_path_placeholders(&ensure_proto(&r.url), &r.url_parameters);\n    let mut url = append_query_params(\n        &url_string,\n        params\n            .iter()\n            .filter(|p| p.enabled && !p.name.is_empty())\n            .map(|p| (p.name.clone(), p.value.clone()))\n            .collect(),\n    );\n\n    // GraphQL GET requests encode query/variables as URL query parameters\n    if r.method.to_lowercase() == \"get\" && r.body_type.as_deref() == Some(\"graphql\") {\n        url = append_graphql_query_params(&url, &r.body);\n    }\n\n    url\n}\n\nfn append_graphql_query_params(url: &str, body: &BTreeMap<String, serde_json::Value>) -> String {\n    let query = get_str_map(body, \"query\").to_string();\n    let variables = strip_json_comments(&get_str_map(body, \"variables\"));\n    let mut params = vec![(\"query\".to_string(), query)];\n    if !variables.trim().is_empty() {\n        params.push((\"variables\".to_string(), variables));\n    }\n    // Strip existing query/variables params to avoid duplicates\n    let url = strip_query_params(url, &[\"query\", \"variables\"]);\n    append_query_params(&url, params)\n}\n\nfn build_headers(r: &HttpRequest) -> Vec<(String, String)> {\n    r.headers\n        .iter()\n        .filter_map(|h| {\n            if h.enabled && !h.name.is_empty() {\n                Some((h.name.clone(), h.value.clone()))\n            } else {\n                None\n            }\n        })\n        .collect()\n}\n\nasync fn build_body(\n    method: &str,\n    body_type: &Option<String>,\n    body: &BTreeMap<String, serde_json::Value>,\n    headers: Vec<(String, String)>,\n) -> Result<(Option<SendableBody>, Vec<(String, String)>)> {\n    let body_type = match &body_type {\n        None => return Ok((None, headers)),\n        Some(t) => t,\n    };\n\n    let (body, content_type) = match body_type.as_str() {\n        \"binary\" => (build_binary_body(&body).await?, None),\n        \"graphql\" => (build_graphql_body(&method, &body), Some(\"application/json\".to_string())),\n        \"application/x-www-form-urlencoded\" => {\n            (build_form_body(&body), Some(\"application/x-www-form-urlencoded\".to_string()))\n        }\n        \"multipart/form-data\" => build_multipart_body(&body, &headers).await?,\n        _ if body.contains_key(\"text\") => (build_text_body(&body, body_type), None),\n        t => {\n            warn!(\"Unsupported body type: {}\", t);\n            (None, None)\n        }\n    };\n\n    // Add or update the Content-Type header\n    let mut headers = headers;\n    if let Some(ct) = content_type {\n        if let Some(existing) = headers.iter_mut().find(|h| h.0.to_lowercase() == \"content-type\") {\n            existing.1 = ct;\n        } else {\n            headers.push((\"Content-Type\".to_string(), ct));\n        }\n    }\n\n    // NOTE: Content-Length is NOT set as an explicit header here. Instead, the\n    // body's content length is carried via SendableBody::Stream { content_length }\n    // and used by the sender to set the body size hint. This lets hyper handle\n    // Content-Length automatically for both HTTP/1.1 and HTTP/2, avoiding the\n    // duplicate Content-Length that breaks HTTP/2 servers.\n\n    Ok((body.map(|b| b.into()), headers))\n}\n\nfn build_form_body(body: &BTreeMap<String, serde_json::Value>) -> Option<SendableBodyWithMeta> {\n    let form_params = match body.get(\"form\").map(|f| f.as_array()) {\n        Some(Some(f)) => f,\n        _ => return None,\n    };\n\n    let mut body = String::new();\n    for p in form_params {\n        let enabled = get_bool(p, \"enabled\", true);\n        let name = get_str(p, \"name\");\n        if !enabled || name.is_empty() {\n            continue;\n        }\n        let value = get_str(p, \"value\");\n        if !body.is_empty() {\n            body.push('&');\n        }\n        body.push_str(&urlencoding::encode(&name));\n        body.push('=');\n        body.push_str(&urlencoding::encode(&value));\n    }\n\n    if body.is_empty() { None } else { Some(SendableBodyWithMeta::Bytes(Bytes::from(body))) }\n}\n\nasync fn build_binary_body(\n    body: &BTreeMap<String, serde_json::Value>,\n) -> Result<Option<SendableBodyWithMeta>> {\n    let file_path = match body.get(\"filePath\").map(|f| f.as_str()) {\n        Some(Some(f)) => f,\n        _ => return Ok(None),\n    };\n\n    // Open a file for streaming\n    let content_length = tokio::fs::metadata(file_path)\n        .await\n        .map_err(|e| RequestError(format!(\"Failed to get file metadata: {}\", e)))?\n        .len();\n\n    let file = tokio::fs::File::open(file_path)\n        .await\n        .map_err(|e| RequestError(format!(\"Failed to open file: {}\", e)))?;\n\n    Ok(Some(SendableBodyWithMeta::Stream {\n        data: Box::pin(file),\n        content_length: Some(content_length as usize),\n    }))\n}\n\nfn build_text_body(body: &BTreeMap<String, serde_json::Value>, body_type: &str) -> Option<SendableBodyWithMeta> {\n    let text = get_str_map(body, \"text\");\n    if text.is_empty() {\n        return None;\n    }\n\n    let send_comments = get_bool_map(body, \"sendJsonComments\", false);\n    let text = if !send_comments && body_type == \"application/json\" {\n        maybe_strip_json_comments(text)\n    } else {\n        text.to_string()\n    };\n\n    Some(SendableBodyWithMeta::Bytes(Bytes::from(text)))\n}\n\nfn build_graphql_body(\n    method: &str,\n    body: &BTreeMap<String, serde_json::Value>,\n) -> Option<SendableBodyWithMeta> {\n    let query = get_str_map(body, \"query\");\n    let variables = strip_json_comments(&get_str_map(body, \"variables\"));\n\n    if method.to_lowercase() == \"get\" {\n        // GraphQL GET requests use query parameters, not a body\n        return None;\n    }\n\n    let body = if variables.trim().is_empty() {\n        format!(r#\"{{\"query\":{}}}\"#, serde_json::to_string(&query).unwrap_or_default())\n    } else {\n        format!(\n            r#\"{{\"query\":{},\"variables\":{}}}\"#,\n            serde_json::to_string(&query).unwrap_or_default(),\n            variables\n        )\n    };\n\n    Some(SendableBodyWithMeta::Bytes(Bytes::from(body)))\n}\n\nasync fn build_multipart_body(\n    body: &BTreeMap<String, serde_json::Value>,\n    headers: &Vec<(String, String)>,\n) -> Result<(Option<SendableBodyWithMeta>, Option<String>)> {\n    let boundary = extract_boundary_from_headers(headers);\n\n    let form_params = match body.get(\"form\").map(|f| f.as_array()) {\n        Some(Some(f)) => f,\n        _ => return Ok((None, None)),\n    };\n\n    // Build a list of readers for streaming and calculate total content length\n    let mut readers: Vec<ReaderType> = Vec::new();\n    let mut has_content = false;\n    let mut total_size: usize = 0;\n\n    for p in form_params {\n        let enabled = get_bool(p, \"enabled\", true);\n        let name = get_str(p, \"name\");\n        if !enabled || name.is_empty() {\n            continue;\n        }\n\n        has_content = true;\n\n        // Add boundary delimiter\n        let boundary_bytes = format!(\"--{}\\r\\n\", boundary).into_bytes();\n        total_size += boundary_bytes.len();\n        readers.push(ReaderType::Bytes(boundary_bytes));\n\n        let file_path = get_str(p, \"file\");\n        let value = get_str(p, \"value\");\n        let content_type = get_str(p, \"contentType\");\n\n        if file_path.is_empty() {\n            // Text field\n            let header = if !content_type.is_empty() {\n                format!(\n                    \"Content-Disposition: form-data; name=\\\"{}\\\"\\r\\nContent-Type: {}\\r\\n\\r\\n{}\",\n                    name, content_type, value\n                )\n            } else {\n                format!(\"Content-Disposition: form-data; name=\\\"{}\\\"\\r\\n\\r\\n{}\", name, value)\n            };\n            let header_bytes = header.into_bytes();\n            total_size += header_bytes.len();\n            readers.push(ReaderType::Bytes(header_bytes));\n        } else {\n            // File field - validate that file exists first\n            if !tokio::fs::try_exists(file_path).await.unwrap_or(false) {\n                return Err(RequestError(format!(\"File not found: {}\", file_path)));\n            }\n\n            // Get file size for content length calculation\n            let file_metadata = tokio::fs::metadata(file_path)\n                .await\n                .map_err(|e| RequestError(format!(\"Failed to get file metadata: {}\", e)))?;\n            let file_size = file_metadata.len() as usize;\n\n            let filename = get_str(p, \"filename\");\n            let filename = if filename.is_empty() {\n                std::path::Path::new(file_path)\n                    .file_name()\n                    .and_then(|n| n.to_str())\n                    .unwrap_or(\"file\")\n            } else {\n                filename\n            };\n\n            // Add content type\n            let mime_type = if !content_type.is_empty() {\n                content_type.to_string()\n            } else {\n                // Guess mime type from file extension\n                mime_guess::from_path(file_path).first_or_octet_stream().to_string()\n            };\n\n            let header = format!(\n                \"Content-Disposition: form-data; name=\\\"{}\\\"; filename=\\\"{}\\\"\\r\\nContent-Type: {}\\r\\n\\r\\n\",\n                name, filename, mime_type\n            );\n            let header_bytes = header.into_bytes();\n            total_size += header_bytes.len();\n            total_size += file_size;\n            readers.push(ReaderType::Bytes(header_bytes));\n\n            // Add a file path for streaming\n            readers.push(ReaderType::FilePath(file_path.to_string()));\n        }\n\n        let line_ending = b\"\\r\\n\".to_vec();\n        total_size += line_ending.len();\n        readers.push(ReaderType::Bytes(line_ending));\n    }\n\n    if has_content {\n        // Add the final boundary\n        let final_boundary = format!(\"--{}--\\r\\n\", boundary).into_bytes();\n        total_size += final_boundary.len();\n        readers.push(ReaderType::Bytes(final_boundary));\n\n        let content_type = format!(\"multipart/form-data; boundary={}\", boundary);\n        let stream = ChainedReader::new(readers);\n        Ok((\n            Some(SendableBodyWithMeta::Stream {\n                data: Box::pin(stream),\n                content_length: Some(total_size),\n            }),\n            Some(content_type),\n        ))\n    } else {\n        Ok((None, None))\n    }\n}\n\nfn extract_boundary_from_headers(headers: &Vec<(String, String)>) -> String {\n    headers\n        .iter()\n        .find(|h| h.0.to_lowercase() == \"content-type\")\n        .and_then(|h| {\n            // Extract boundary from the Content-Type header (e.g., \"multipart/form-data; boundary=xyz\")\n            h.1.split(';')\n                .find(|part| part.trim().starts_with(\"boundary=\"))\n                .and_then(|boundary_part| boundary_part.split('=').nth(1))\n                .map(|b| b.trim().to_string())\n        })\n        .unwrap_or_else(|| MULTIPART_BOUNDARY.to_string())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use bytes::Bytes;\n    use serde_json::json;\n    use std::collections::BTreeMap;\n    use yaak_models::models::{HttpRequest, HttpUrlParameter};\n\n    #[test]\n    fn test_build_url_no_params() {\n        let r = HttpRequest {\n            url: \"https://example.com/api\".to_string(),\n            url_parameters: vec![],\n            ..Default::default()\n        };\n\n        let result = build_url(&r);\n        assert_eq!(result, \"https://example.com/api\");\n    }\n\n    #[test]\n    fn test_build_url_with_params() {\n        let r = HttpRequest {\n            url: \"https://example.com/api\".to_string(),\n            url_parameters: vec![\n                HttpUrlParameter {\n                    enabled: true,\n                    name: \"foo\".to_string(),\n                    value: \"bar\".to_string(),\n                    id: None,\n                },\n                HttpUrlParameter {\n                    enabled: true,\n                    name: \"baz\".to_string(),\n                    value: \"qux\".to_string(),\n                    id: None,\n                },\n            ],\n            ..Default::default()\n        };\n\n        let result = build_url(&r);\n        assert_eq!(result, \"https://example.com/api?foo=bar&baz=qux\");\n    }\n\n    #[test]\n    fn test_build_url_with_disabled_params() {\n        let r = HttpRequest {\n            url: \"https://example.com/api\".to_string(),\n            url_parameters: vec![\n                HttpUrlParameter {\n                    enabled: false,\n                    name: \"disabled\".to_string(),\n                    value: \"value\".to_string(),\n                    id: None,\n                },\n                HttpUrlParameter {\n                    enabled: true,\n                    name: \"enabled\".to_string(),\n                    value: \"value\".to_string(),\n                    id: None,\n                },\n            ],\n            ..Default::default()\n        };\n\n        let result = build_url(&r);\n        assert_eq!(result, \"https://example.com/api?enabled=value\");\n    }\n\n    #[test]\n    fn test_build_url_with_existing_query() {\n        let r = HttpRequest {\n            url: \"https://example.com/api?existing=param\".to_string(),\n            url_parameters: vec![HttpUrlParameter {\n                enabled: true,\n                name: \"new\".to_string(),\n                value: \"value\".to_string(),\n                id: None,\n            }],\n            ..Default::default()\n        };\n\n        let result = build_url(&r);\n        assert_eq!(result, \"https://example.com/api?existing=param&new=value\");\n    }\n\n    #[test]\n    fn test_build_url_with_empty_existing_query() {\n        let r = HttpRequest {\n            url: \"https://example.com/api?\".to_string(),\n            url_parameters: vec![HttpUrlParameter {\n                enabled: true,\n                name: \"new\".to_string(),\n                value: \"value\".to_string(),\n                id: None,\n            }],\n            ..Default::default()\n        };\n\n        let result = build_url(&r);\n        assert_eq!(result, \"https://example.com/api?new=value\");\n    }\n\n    #[test]\n    fn test_build_url_with_special_chars() {\n        let r = HttpRequest {\n            url: \"https://example.com/api\".to_string(),\n            url_parameters: vec![HttpUrlParameter {\n                enabled: true,\n                name: \"special chars!@#\".to_string(),\n                value: \"value with spaces & symbols\".to_string(),\n                id: None,\n            }],\n            ..Default::default()\n        };\n\n        let result = build_url(&r);\n        assert_eq!(\n            result,\n            \"https://example.com/api?special%20chars%21%40%23=value%20with%20spaces%20%26%20symbols\"\n        );\n    }\n\n    #[test]\n    fn test_build_url_adds_protocol() {\n        let r = HttpRequest {\n            url: \"example.com/api\".to_string(),\n            url_parameters: vec![HttpUrlParameter {\n                enabled: true,\n                name: \"foo\".to_string(),\n                value: \"bar\".to_string(),\n                id: None,\n            }],\n            ..Default::default()\n        };\n\n        let result = build_url(&r);\n        // ensure_proto defaults to http:// for regular domains\n        assert_eq!(result, \"http://example.com/api?foo=bar\");\n    }\n\n    #[test]\n    fn test_build_url_adds_https_for_dev_domain() {\n        let r = HttpRequest {\n            url: \"example.dev/api\".to_string(),\n            url_parameters: vec![HttpUrlParameter {\n                enabled: true,\n                name: \"foo\".to_string(),\n                value: \"bar\".to_string(),\n                id: None,\n            }],\n            ..Default::default()\n        };\n\n        let result = build_url(&r);\n        // .dev domains force https\n        assert_eq!(result, \"https://example.dev/api?foo=bar\");\n    }\n\n    #[test]\n    fn test_build_url_with_fragment() {\n        let r = HttpRequest {\n            url: \"https://example.com/api#section\".to_string(),\n            url_parameters: vec![HttpUrlParameter {\n                enabled: true,\n                name: \"foo\".to_string(),\n                value: \"bar\".to_string(),\n                id: None,\n            }],\n            ..Default::default()\n        };\n\n        let result = build_url(&r);\n        assert_eq!(result, \"https://example.com/api?foo=bar#section\");\n    }\n\n    #[test]\n    fn test_build_url_with_existing_query_and_fragment() {\n        let r = HttpRequest {\n            url: \"https://yaak.app?foo=bar#some-hash\".to_string(),\n            url_parameters: vec![HttpUrlParameter {\n                enabled: true,\n                name: \"baz\".to_string(),\n                value: \"qux\".to_string(),\n                id: None,\n            }],\n            ..Default::default()\n        };\n\n        let result = build_url(&r);\n        assert_eq!(result, \"https://yaak.app?foo=bar&baz=qux#some-hash\");\n    }\n\n    #[test]\n    fn test_build_url_with_empty_query_and_fragment() {\n        let r = HttpRequest {\n            url: \"https://example.com/api?#section\".to_string(),\n            url_parameters: vec![HttpUrlParameter {\n                enabled: true,\n                name: \"foo\".to_string(),\n                value: \"bar\".to_string(),\n                id: None,\n            }],\n            ..Default::default()\n        };\n\n        let result = build_url(&r);\n        assert_eq!(result, \"https://example.com/api?foo=bar#section\");\n    }\n\n    #[test]\n    fn test_build_url_with_fragment_containing_special_chars() {\n        let r = HttpRequest {\n            url: \"https://example.com#section/with/slashes?and=fake&query\".to_string(),\n            url_parameters: vec![HttpUrlParameter {\n                enabled: true,\n                name: \"real\".to_string(),\n                value: \"param\".to_string(),\n                id: None,\n            }],\n            ..Default::default()\n        };\n\n        let result = build_url(&r);\n        assert_eq!(result, \"https://example.com?real=param#section/with/slashes?and=fake&query\");\n    }\n\n    #[test]\n    fn test_build_url_preserves_empty_fragment() {\n        let r = HttpRequest {\n            url: \"https://example.com/api#\".to_string(),\n            url_parameters: vec![HttpUrlParameter {\n                enabled: true,\n                name: \"foo\".to_string(),\n                value: \"bar\".to_string(),\n                id: None,\n            }],\n            ..Default::default()\n        };\n\n        let result = build_url(&r);\n        assert_eq!(result, \"https://example.com/api?foo=bar#\");\n    }\n\n    #[test]\n    fn test_build_url_with_multiple_fragments() {\n        // Testing edge case where the URL has multiple # characters (though technically invalid)\n        let r = HttpRequest {\n            url: \"https://example.com#section#subsection\".to_string(),\n            url_parameters: vec![HttpUrlParameter {\n                enabled: true,\n                name: \"foo\".to_string(),\n                value: \"bar\".to_string(),\n                id: None,\n            }],\n            ..Default::default()\n        };\n\n        let result = build_url(&r);\n        // Should treat everything after first # as fragment\n        assert_eq!(result, \"https://example.com?foo=bar#section#subsection\");\n    }\n\n    #[tokio::test]\n    async fn test_text_body() {\n        let mut body = BTreeMap::new();\n        body.insert(\"text\".to_string(), json!(\"Hello, World!\"));\n\n        let result = build_text_body(&body, \"application/json\");\n        match result {\n            Some(SendableBodyWithMeta::Bytes(bytes)) => {\n                assert_eq!(bytes, Bytes::from(\"Hello, World!\"))\n            }\n            _ => panic!(\"Expected Some(SendableBody::Bytes)\"),\n        }\n    }\n\n    #[tokio::test]\n    async fn test_text_body_empty() {\n        let mut body = BTreeMap::new();\n        body.insert(\"text\".to_string(), json!(\"\"));\n\n        let result = build_text_body(&body, \"application/json\");\n        assert!(result.is_none());\n    }\n\n    #[tokio::test]\n    async fn test_text_body_missing() {\n        let body = BTreeMap::new();\n\n        let result = build_text_body(&body, \"application/json\");\n        assert!(result.is_none());\n    }\n\n    #[tokio::test]\n    async fn test_text_body_strips_json_comments_by_default() {\n        let mut body = BTreeMap::new();\n        body.insert(\"text\".to_string(), json!(\"{\\n  // comment\\n  \\\"foo\\\": \\\"bar\\\"\\n}\"));\n\n        let result = build_text_body(&body, \"application/json\");\n        match result {\n            Some(SendableBodyWithMeta::Bytes(bytes)) => {\n                let text = String::from_utf8_lossy(&bytes);\n                assert!(!text.contains(\"// comment\"));\n                assert!(text.contains(\"\\\"foo\\\": \\\"bar\\\"\"));\n            }\n            _ => panic!(\"Expected Some(SendableBody::Bytes)\"),\n        }\n    }\n\n    #[tokio::test]\n    async fn test_text_body_send_json_comments_when_opted_in() {\n        let mut body = BTreeMap::new();\n        body.insert(\"text\".to_string(), json!(\"{\\n  // comment\\n  \\\"foo\\\": \\\"bar\\\"\\n}\"));\n        body.insert(\"sendJsonComments\".to_string(), json!(true));\n\n        let result = build_text_body(&body, \"application/json\");\n        match result {\n            Some(SendableBodyWithMeta::Bytes(bytes)) => {\n                let text = String::from_utf8_lossy(&bytes);\n                assert!(text.contains(\"// comment\"));\n            }\n            _ => panic!(\"Expected Some(SendableBody::Bytes)\"),\n        }\n    }\n\n    #[tokio::test]\n    async fn test_text_body_no_strip_for_non_json() {\n        let mut body = BTreeMap::new();\n        body.insert(\"text\".to_string(), json!(\"// not json\\nsome text\"));\n\n        let result = build_text_body(&body, \"text/plain\");\n        match result {\n            Some(SendableBodyWithMeta::Bytes(bytes)) => {\n                let text = String::from_utf8_lossy(&bytes);\n                assert!(text.contains(\"// not json\"));\n            }\n            _ => panic!(\"Expected Some(SendableBody::Bytes)\"),\n        }\n    }\n\n    #[tokio::test]\n    async fn test_form_urlencoded_body() -> Result<()> {\n        let mut body = BTreeMap::new();\n        body.insert(\n            \"form\".to_string(),\n            json!([\n                { \"enabled\": true, \"name\": \"basic\", \"value\": \"aaa\"},\n                { \"enabled\": true, \"name\": \"fUnkey Stuff!$*#(\", \"value\": \"*)%&#$)@ *$#)@&\"},\n                { \"enabled\": false, \"name\": \"disabled\", \"value\": \"won't show\"},\n            ]),\n        );\n\n        let result = build_form_body(&body);\n        match result {\n            Some(SendableBodyWithMeta::Bytes(bytes)) => {\n                let expected = \"basic=aaa&fUnkey%20Stuff%21%24%2A%23%28=%2A%29%25%26%23%24%29%40%20%2A%24%23%29%40%26\";\n                assert_eq!(bytes, Bytes::from(expected));\n            }\n            _ => panic!(\"Expected Some(SendableBody::Bytes)\"),\n        }\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_form_urlencoded_body_missing_form() {\n        let body = BTreeMap::new();\n        let result = build_form_body(&body);\n        assert!(result.is_none());\n    }\n\n    #[tokio::test]\n    async fn test_binary_body() -> Result<()> {\n        let mut body = BTreeMap::new();\n        body.insert(\"filePath\".to_string(), json!(\"./tests/test.txt\"));\n\n        let result = build_binary_body(&body).await?;\n        assert!(matches!(result, Some(SendableBodyWithMeta::Stream { .. })));\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_binary_body_file_not_found() {\n        let mut body = BTreeMap::new();\n        body.insert(\"filePath\".to_string(), json!(\"./nonexistent/file.txt\"));\n\n        let result = build_binary_body(&body).await;\n        assert!(result.is_err());\n        if let Err(e) = result {\n            assert!(matches!(e, RequestError(_)));\n        }\n    }\n\n    #[tokio::test]\n    async fn test_graphql_body_with_variables() {\n        let mut body = BTreeMap::new();\n        body.insert(\"query\".to_string(), json!(\"{ user(id: $id) { name } }\"));\n        body.insert(\"variables\".to_string(), json!(r#\"{\"id\": \"123\"}\"#));\n\n        let result = build_graphql_body(\"POST\", &body);\n        match result {\n            Some(SendableBodyWithMeta::Bytes(bytes)) => {\n                let expected =\n                    r#\"{\"query\":\"{ user(id: $id) { name } }\",\"variables\":{\"id\": \"123\"}}\"#;\n                assert_eq!(bytes, Bytes::from(expected));\n            }\n            _ => panic!(\"Expected Some(SendableBody::Bytes)\"),\n        }\n    }\n\n    #[tokio::test]\n    async fn test_graphql_body_without_variables() {\n        let mut body = BTreeMap::new();\n        body.insert(\"query\".to_string(), json!(\"{ users { name } }\"));\n        body.insert(\"variables\".to_string(), json!(\"\"));\n\n        let result = build_graphql_body(\"POST\", &body);\n        match result {\n            Some(SendableBodyWithMeta::Bytes(bytes)) => {\n                let expected = r#\"{\"query\":\"{ users { name } }\"}\"#;\n                assert_eq!(bytes, Bytes::from(expected));\n            }\n            _ => panic!(\"Expected Some(SendableBody::Bytes)\"),\n        }\n    }\n\n    #[tokio::test]\n    async fn test_graphql_body_get_method() {\n        let mut body = BTreeMap::new();\n        body.insert(\"query\".to_string(), json!(\"{ users { name } }\"));\n\n        let result = build_graphql_body(\"GET\", &body);\n        assert!(result.is_none());\n    }\n\n    #[tokio::test]\n    async fn test_multipart_body_text_fields() -> Result<()> {\n        let mut body = BTreeMap::new();\n        body.insert(\n            \"form\".to_string(),\n            json!([\n                { \"enabled\": true, \"name\": \"field1\", \"value\": \"value1\", \"file\": \"\" },\n                { \"enabled\": true, \"name\": \"field2\", \"value\": \"value2\", \"file\": \"\" },\n                { \"enabled\": false, \"name\": \"disabled\", \"value\": \"won't show\", \"file\": \"\" },\n            ]),\n        );\n\n        let (result, content_type) = build_multipart_body(&body, &vec![]).await?;\n        assert!(content_type.is_some());\n\n        match result {\n            Some(SendableBodyWithMeta::Stream { data: mut stream, content_length }) => {\n                // Read the entire stream to verify content\n                let mut buf = Vec::new();\n                use tokio::io::AsyncReadExt;\n                stream.read_to_end(&mut buf).await.expect(\"Failed to read stream\");\n                let body_str = String::from_utf8_lossy(&buf);\n                assert_eq!(\n                    body_str,\n                    \"--------YaakFormBoundary\\r\\nContent-Disposition: form-data; name=\\\"field1\\\"\\r\\n\\r\\nvalue1\\r\\n--------YaakFormBoundary\\r\\nContent-Disposition: form-data; name=\\\"field2\\\"\\r\\n\\r\\nvalue2\\r\\n--------YaakFormBoundary--\\r\\n\",\n                );\n                assert_eq!(content_length, Some(body_str.len()));\n            }\n            _ => panic!(\"Expected Some(SendableBody::Stream)\"),\n        }\n\n        assert_eq!(\n            content_type.unwrap(),\n            format!(\"multipart/form-data; boundary={}\", MULTIPART_BOUNDARY)\n        );\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_multipart_body_with_file() -> Result<()> {\n        let mut body = BTreeMap::new();\n        body.insert(\n            \"form\".to_string(),\n            json!([\n                { \"enabled\": true, \"name\": \"file_field\", \"file\": \"./tests/test.txt\", \"filename\": \"custom.txt\", \"contentType\": \"text/plain\" },\n            ]),\n        );\n\n        let (result, content_type) = build_multipart_body(&body, &vec![]).await?;\n        assert!(content_type.is_some());\n\n        match result {\n            Some(SendableBodyWithMeta::Stream { data: mut stream, content_length }) => {\n                // Read the entire stream to verify content\n                let mut buf = Vec::new();\n                use tokio::io::AsyncReadExt;\n                stream.read_to_end(&mut buf).await.expect(\"Failed to read stream\");\n                let body_str = String::from_utf8_lossy(&buf);\n                assert_eq!(\n                    body_str,\n                    \"--------YaakFormBoundary\\r\\nContent-Disposition: form-data; name=\\\"file_field\\\"; filename=\\\"custom.txt\\\"\\r\\nContent-Type: text/plain\\r\\n\\r\\nThis is a test file!\\n\\r\\n--------YaakFormBoundary--\\r\\n\"\n                );\n                assert_eq!(content_length, Some(body_str.len()));\n            }\n            _ => panic!(\"Expected Some(SendableBody::Stream)\"),\n        }\n\n        assert_eq!(\n            content_type.unwrap(),\n            format!(\"multipart/form-data; boundary={}\", MULTIPART_BOUNDARY)\n        );\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_multipart_body_empty() -> Result<()> {\n        let body = BTreeMap::new();\n        let (result, content_type) = build_multipart_body(&body, &vec![]).await?;\n        assert!(result.is_none());\n        assert_eq!(content_type, None);\n        Ok(())\n    }\n\n    #[test]\n    fn test_extract_boundary_from_headers_with_custom_boundary() {\n        let headers = vec![(\n            \"Content-Type\".to_string(),\n            \"multipart/form-data; boundary=customBoundary123\".to_string(),\n        )];\n        let boundary = extract_boundary_from_headers(&headers);\n        assert_eq!(boundary, \"customBoundary123\");\n    }\n\n    #[test]\n    fn test_extract_boundary_from_headers_default() {\n        let headers = vec![(\"Accept\".to_string(), \"*/*\".to_string())];\n        let boundary = extract_boundary_from_headers(&headers);\n        assert_eq!(boundary, MULTIPART_BOUNDARY);\n    }\n\n    #[test]\n    fn test_extract_boundary_from_headers_no_boundary_in_content_type() {\n        let headers = vec![(\"Content-Type\".to_string(), \"multipart/form-data\".to_string())];\n        let boundary = extract_boundary_from_headers(&headers);\n        assert_eq!(boundary, MULTIPART_BOUNDARY);\n    }\n\n    #[test]\n    fn test_extract_boundary_case_insensitive() {\n        let headers = vec![(\n            \"Content-Type\".to_string(),\n            \"multipart/form-data; boundary=myBoundary\".to_string(),\n        )];\n        let boundary = extract_boundary_from_headers(&headers);\n        assert_eq!(boundary, \"myBoundary\");\n    }\n\n    #[tokio::test]\n    async fn test_no_content_length_header_added_by_build_body() -> Result<()> {\n        let mut body = BTreeMap::new();\n        body.insert(\"text\".to_string(), json!(\"Hello, World!\"));\n\n        let headers = vec![];\n\n        let (_, result_headers) =\n            build_body(\"POST\", &Some(\"text/plain\".to_string()), &body, headers).await?;\n\n        // Content-Length should NOT be set as an explicit header. Instead, the\n        // sender uses the body's size_hint to let hyper set it automatically,\n        // which works correctly for both HTTP/1.1 and HTTP/2.\n        let has_content_length =\n            result_headers.iter().any(|h| h.0.to_lowercase() == \"content-length\");\n        assert!(!has_content_length, \"Content-Length should not be set as an explicit header\");\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn test_chunked_encoding_header_preserved() -> Result<()> {\n        let mut body = BTreeMap::new();\n        body.insert(\"text\".to_string(), json!(\"Hello, World!\"));\n\n        // Headers with Transfer-Encoding: chunked\n        let headers = vec![(\"Transfer-Encoding\".to_string(), \"chunked\".to_string())];\n\n        let (_, result_headers) =\n            build_body(\"POST\", &Some(\"text/plain\".to_string()), &body, headers).await?;\n\n        // Verify that the Transfer-Encoding header is still present\n        let has_chunked = result_headers.iter().any(|h| {\n            h.0.to_lowercase() == \"transfer-encoding\" && h.1.to_lowercase().contains(\"chunked\")\n        });\n        assert!(has_chunked, \"Transfer-Encoding: chunked should be preserved\");\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/yaak-http/tests/test.txt",
    "content": "This is a test file!\n"
  },
  {
    "path": "crates/yaak-models/Cargo.toml",
    "content": "[package]\nname = \"yaak-models\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nchrono = { version = \"0.4.38\", features = [\"serde\"] }\nhex = { workspace = true }\ninclude_dir = \"0.7\"\nlog = { workspace = true }\nnanoid = \"0.4.0\"\nr2d2 = \"0.8.10\"\nr2d2_sqlite = { version = \"0.25.0\" }\nrusqlite = { version = \"0.32.1\", features = [\"bundled\", \"chrono\"] }\nsea-query = { version = \"0.32.1\", features = [\"with-chrono\", \"attr\"] }\nsea-query-rusqlite = { version = \"0.7.0\", features = [\"with-chrono\"] }\nserde = { workspace = true, features = [\"derive\"] }\nserde_json = { workspace = true }\nschemars = { workspace = true }\nsha2 = { workspace = true }\nthiserror = { workspace = true }\nts-rs = { workspace = true, features = [\"chrono-impl\", \"serde-json-impl\"] }\nyaak-core = { workspace = true }\n"
  },
  {
    "path": "crates/yaak-models/bindings/gen_models.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;\n\nexport type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };\n\nexport type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], };\n\nexport type CookieDomain = { \"HostOnly\": string } | { \"Suffix\": string } | \"NotPresent\" | \"Empty\";\n\nexport type CookieExpires = { \"AtUtc\": string } | \"SessionEnd\";\n\nexport type CookieJar = { model: \"cookie_jar\", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };\n\nexport type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };\n\nexport type EditorKeymap = \"default\" | \"vim\" | \"vscode\" | \"emacs\";\n\nexport type EncryptedKey = { encryptedKey: string, };\n\nexport type Environment = { model: \"environment\", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };\n\nexport type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };\n\nexport type Folder = { model: \"folder\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };\n\nexport type GraphQlIntrospection = { model: \"graphql_introspection\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };\n\nexport type GrpcConnection = { model: \"grpc_connection\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };\n\nexport type GrpcConnectionState = \"initialized\" | \"connected\" | \"closed\";\n\nexport type GrpcEvent = { model: \"grpc_event\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };\n\nexport type GrpcEventType = \"info\" | \"error\" | \"client_message\" | \"server_message\" | \"connection_start\" | \"connection_end\";\n\nexport type GrpcRequest = { model: \"grpc_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };\n\nexport type HttpRequest = { model: \"http_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };\n\nexport type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };\n\nexport type HttpResponse = { model: \"http_response\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };\n\nexport type HttpResponseEvent = { model: \"http_response_event\", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };\n\n/**\n * Serializable representation of HTTP response events for DB storage.\n * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.\n * The `From` impl is in yaak-http to avoid circular dependencies.\n */\nexport type HttpResponseEventData = { \"type\": \"setting\", name: string, value: string, } | { \"type\": \"info\", message: string, } | { \"type\": \"redirect\", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { \"type\": \"send_url\", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { \"type\": \"receive_url\", version: string, status: string, } | { \"type\": \"header_up\", name: string, value: string, } | { \"type\": \"header_down\", name: string, value: string, } | { \"type\": \"chunk_sent\", bytes: number, } | { \"type\": \"chunk_received\", bytes: number, } | { \"type\": \"dns_resolved\", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };\n\nexport type HttpResponseHeader = { name: string, value: string, };\n\nexport type HttpResponseState = \"initialized\" | \"connected\" | \"closed\";\n\nexport type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, };\n\nexport type KeyValue = { model: \"key_value\", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };\n\nexport type ModelChangeEvent = { \"type\": \"upsert\", created: boolean, } | { \"type\": \"delete\" };\n\nexport type ModelPayload = { model: AnyModel, updateSource: UpdateSource, change: ModelChangeEvent, };\n\nexport type ParentAuthentication = { authentication: Record<string, any>, authenticationType: string | null, };\n\nexport type ParentHeaders = { headers: Array<HttpRequestHeader>, };\n\nexport type Plugin = { model: \"plugin\", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };\n\nexport type PluginSource = \"bundled\" | \"filesystem\" | \"registry\";\n\nexport type PluginKeyValue = { model: \"plugin_key_value\", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, };\n\nexport type ProxySetting = { \"type\": \"enabled\", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { \"type\": \"disabled\" };\n\nexport type ProxySettingAuth = { user: string, password: string, };\n\nexport type Settings = { model: \"settings\", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array<string> }, };\n\nexport type SyncState = { model: \"sync_state\", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };\n\nexport type UpdateSource = { \"type\": \"background\" } | { \"type\": \"import\" } | { \"type\": \"plugin\" } | { \"type\": \"sync\" } | { \"type\": \"window\", label: string, };\n\nexport type WebsocketConnection = { model: \"websocket_connection\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };\n\nexport type WebsocketConnectionState = \"initialized\" | \"connected\" | \"closing\" | \"closed\";\n\nexport type WebsocketEvent = { model: \"websocket_event\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };\n\nexport type WebsocketEventType = \"binary\" | \"close\" | \"frame\" | \"open\" | \"ping\" | \"pong\" | \"text\";\n\nexport type WebsocketMessageType = \"text\" | \"binary\";\n\nexport type WebsocketRequest = { model: \"websocket_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };\n\nexport type Workspace = { model: \"workspace\", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };\n\nexport type WorkspaceMeta = { model: \"workspace_meta\", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };\n"
  },
  {
    "path": "crates/yaak-models/bindings/gen_util.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\nimport type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from \"./gen_models\";\n\nexport type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };\n"
  },
  {
    "path": "crates/yaak-models/blob_migrations/00000000000000_init.sql",
    "content": "CREATE TABLE body_chunks\n(\n    id          TEXT PRIMARY KEY,\n    body_id     TEXT    NOT NULL,\n    chunk_index INTEGER NOT NULL,\n    data        BLOB    NOT NULL,\n    created_at  DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,\n\n    UNIQUE (body_id, chunk_index)\n);\n\nCREATE INDEX idx_body_chunks_body_id ON body_chunks (body_id, chunk_index);\n"
  },
  {
    "path": "crates/yaak-models/build.rs",
    "content": "fn main() {\n    // Migrations are embedded with include_dir!, so trigger rebuilds when SQL files change.\n    println!(\"cargo:rerun-if-changed=migrations\");\n    println!(\"cargo:rerun-if-changed=blob_migrations\");\n}\n"
  },
  {
    "path": "crates/yaak-models/guest-js/atoms.ts",
    "content": "import { atom } from \"jotai\";\n\nimport { selectAtom } from \"jotai/utils\";\nimport type { AnyModel } from \"../bindings/gen_models\";\nimport { ExtractModel } from \"./types\";\nimport { newStoreData } from \"./util\";\n\nexport const modelStoreDataAtom = atom(newStoreData());\n\nexport const cookieJarsAtom = createOrderedModelAtom(\"cookie_jar\", \"name\", \"asc\");\nexport const environmentsAtom = createOrderedModelAtom(\"environment\", \"sortPriority\", \"asc\");\nexport const foldersAtom = createModelAtom(\"folder\");\nexport const grpcConnectionsAtom = createOrderedModelAtom(\"grpc_connection\", \"createdAt\", \"desc\");\nexport const grpcEventsAtom = createOrderedModelAtom(\"grpc_event\", \"createdAt\", \"asc\");\nexport const grpcRequestsAtom = createModelAtom(\"grpc_request\");\nexport const httpRequestsAtom = createModelAtom(\"http_request\");\nexport const httpResponsesAtom = createOrderedModelAtom(\"http_response\", \"createdAt\", \"desc\");\nexport const httpResponseEventsAtom = createOrderedModelAtom(\n  \"http_response_event\",\n  \"createdAt\",\n  \"asc\",\n);\nexport const keyValuesAtom = createModelAtom(\"key_value\");\nexport const pluginsAtom = createModelAtom(\"plugin\");\nexport const settingsAtom = createSingularModelAtom(\"settings\");\nexport const websocketRequestsAtom = createModelAtom(\"websocket_request\");\nexport const websocketEventsAtom = createOrderedModelAtom(\"websocket_event\", \"createdAt\", \"asc\");\nexport const websocketConnectionsAtom = createOrderedModelAtom(\n  \"websocket_connection\",\n  \"createdAt\",\n  \"desc\",\n);\nexport const workspaceMetasAtom = createModelAtom(\"workspace_meta\");\nexport const workspacesAtom = createOrderedModelAtom(\"workspace\", \"name\", \"asc\");\n\nexport function createModelAtom<M extends AnyModel[\"model\"]>(modelType: M) {\n  return selectAtom(\n    modelStoreDataAtom,\n    (data) => Object.values(data[modelType] ?? {}),\n    shallowEqual,\n  );\n}\n\nexport function createSingularModelAtom<M extends AnyModel[\"model\"]>(modelType: M) {\n  return selectAtom(modelStoreDataAtom, (data) => {\n    const modelData = Object.values(data[modelType] ?? {});\n    const item = modelData[0];\n    if (item == null) throw new Error(\"Failed creating singular model with no data: \" + modelType);\n    return item;\n  });\n}\n\nexport function createOrderedModelAtom<M extends AnyModel[\"model\"]>(\n  modelType: M,\n  field: keyof ExtractModel<AnyModel, M>,\n  order: \"asc\" | \"desc\",\n) {\n  return selectAtom(\n    modelStoreDataAtom,\n    (data) => {\n      const modelData = data[modelType] ?? {};\n      return Object.values(modelData).sort(\n        (a: ExtractModel<AnyModel, M>, b: ExtractModel<AnyModel, M>) => {\n          const n = a[field] > b[field] ? 1 : -1;\n          return order === \"desc\" ? n * -1 : n;\n        },\n      );\n    },\n    shallowEqual,\n  );\n}\n\nfunction shallowEqual<T>(a: T[], b: T[]): boolean {\n  if (a.length !== b.length) {\n    return false;\n  }\n\n  for (let i = 0; i < a.length; i++) {\n    if (a[i] !== b[i]) {\n      return false;\n    }\n  }\n\n  return true;\n}\n"
  },
  {
    "path": "crates/yaak-models/guest-js/index.ts",
    "content": "import { AnyModel } from \"../bindings/gen_models\";\n\nexport * from \"../bindings/gen_models\";\nexport * from \"../bindings/gen_util\";\nexport * from \"./store\";\nexport * from \"./atoms\";\n\nexport function modelTypeLabel(m: AnyModel): string {\n  const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);\n  return m.model.split(\"_\").map(capitalize).join(\" \");\n}\n"
  },
  {
    "path": "crates/yaak-models/guest-js/store.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport { getCurrentWebviewWindow } from \"@tauri-apps/api/webviewWindow\";\nimport { resolvedModelName } from \"@yaakapp/app/lib/resolvedModelName\";\nimport { AnyModel, ModelPayload } from \"../bindings/gen_models\";\nimport { modelStoreDataAtom } from \"./atoms\";\nimport { ExtractModel, JotaiStore, ModelStoreData } from \"./types\";\nimport { newStoreData } from \"./util\";\n\nlet _store: JotaiStore | null = null;\n\nexport function initModelStore(store: JotaiStore) {\n  _store = store;\n\n  getCurrentWebviewWindow()\n    .listen<ModelPayload>(\"model_write\", ({ payload }) => {\n      if (shouldIgnoreModel(payload)) return;\n\n      mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {\n        if (payload.change.type === \"upsert\") {\n          return {\n            ...prev,\n            [payload.model.model]: {\n              ...prev[payload.model.model],\n              [payload.model.id]: payload.model,\n            },\n          };\n        } else {\n          const modelData = { ...prev[payload.model.model] };\n          delete modelData[payload.model.id];\n          return { ...prev, [payload.model.model]: modelData };\n        }\n      });\n    })\n    .catch(console.error);\n}\n\nfunction mustStore(): JotaiStore {\n  if (_store == null) {\n    throw new Error(\"Model store was not initialized\");\n  }\n\n  return _store;\n}\n\nlet _activeWorkspaceId: string | null = null;\n\nexport async function changeModelStoreWorkspace(workspaceId: string | null) {\n  console.log(\"Syncing models with new workspace\", workspaceId);\n  const workspaceModelsStr = await invoke<string>(\"models_workspace_models\", {\n    workspaceId, // NOTE: if no workspace id provided, it will just fetch global models\n  });\n  const workspaceModels = JSON.parse(workspaceModelsStr) as AnyModel[];\n  const data = newStoreData();\n  for (const model of workspaceModels) {\n    data[model.model][model.id] = model;\n  }\n\n  mustStore().set(modelStoreDataAtom, data);\n\n  console.log(\"Synced model store with workspace\", workspaceId, data);\n\n  _activeWorkspaceId = workspaceId;\n}\n\nexport function listModels<M extends AnyModel[\"model\"], T extends ExtractModel<AnyModel, M>>(\n  modelType: M | ReadonlyArray<M>,\n): T[] {\n  let data = mustStore().get(modelStoreDataAtom);\n  const types: ReadonlyArray<M> = Array.isArray(modelType) ? modelType : [modelType];\n  return types.flatMap((t) => Object.values(data[t]) as T[]);\n}\n\nexport function getModel<M extends AnyModel[\"model\"], T extends ExtractModel<AnyModel, M>>(\n  modelType: M | ReadonlyArray<M>,\n  id: string,\n): T | null {\n  let data = mustStore().get(modelStoreDataAtom);\n  const types: ReadonlyArray<M> = Array.isArray(modelType) ? modelType : [modelType];\n  for (const t of types) {\n    let v = data[t][id];\n    if (v?.model === t) return v as T;\n  }\n  return null;\n}\n\nexport function getAnyModel(id: string): AnyModel | null {\n  let data = mustStore().get(modelStoreDataAtom);\n  for (const t of Object.keys(data)) {\n    // oxlint-disable-next-line no-explicit-any\n    let v = (data as any)[t]?.[id];\n    if (v?.model === t) return v;\n  }\n  return null;\n}\n\nexport function patchModelById<M extends AnyModel[\"model\"], T extends ExtractModel<AnyModel, M>>(\n  model: M,\n  id: string,\n  patch: Partial<T> | ((prev: T) => T),\n): Promise<string> {\n  let prev = getModel<M, T>(model, id);\n  if (prev == null) {\n    throw new Error(`Failed to get model to patch id=${id} model=${model}`);\n  }\n\n  const newModel = typeof patch === \"function\" ? patch(prev) : { ...prev, ...patch };\n  return updateModel(newModel);\n}\n\nexport async function patchModel<M extends AnyModel[\"model\"], T extends ExtractModel<AnyModel, M>>(\n  base: Pick<T, \"id\" | \"model\">,\n  patch: Partial<T>,\n): Promise<string> {\n  return patchModelById<M, T>(base.model, base.id, patch);\n}\n\nexport async function updateModel<M extends AnyModel[\"model\"], T extends ExtractModel<AnyModel, M>>(\n  model: T,\n): Promise<string> {\n  return invoke<string>(\"models_upsert\", { model });\n}\n\nexport async function deleteModelById<\n  M extends AnyModel[\"model\"],\n  T extends ExtractModel<AnyModel, M>,\n>(modelType: M | M[], id: string) {\n  let model = getModel<M, T>(modelType, id);\n  await deleteModel(model);\n}\n\nexport async function deleteModel<M extends AnyModel[\"model\"], T extends ExtractModel<AnyModel, M>>(\n  model: T | null,\n) {\n  if (model == null) {\n    throw new Error(\"Failed to delete null model\");\n  }\n  await invoke<string>(\"models_delete\", { model });\n}\n\nexport function duplicateModel<M extends AnyModel[\"model\"], T extends ExtractModel<AnyModel, M>>(\n  model: T | null,\n) {\n  if (model == null) {\n    throw new Error(\"Failed to duplicate null model\");\n  }\n\n  // If the model has a name, try to duplicate it with a name that doesn't conflict\n  let name = \"name\" in model ? resolvedModelName(model) : undefined;\n  if (name != null) {\n    const existingModels = listModels(model.model);\n    for (let i = 0; i < 100; i++) {\n      const hasConflict = existingModels.some((m) => {\n        if (\"folderId\" in m && \"folderId\" in model && model.folderId !== m.folderId) {\n          return false;\n        } else if (resolvedModelName(m) !== name) {\n          return false;\n        }\n        return true;\n      });\n      if (!hasConflict) {\n        break;\n      }\n\n      // Name conflict. Try another one\n      const m: RegExpMatchArray | null = name.match(/ Copy( (?<n>\\d+))?$/);\n      if (m != null && m.groups?.n == null) {\n        name = name.substring(0, m.index) + \" Copy 2\";\n      } else if (m != null && m.groups?.n != null) {\n        name = name.substring(0, m.index) + ` Copy ${parseInt(m.groups.n) + 1}`;\n      } else {\n        name = `${name} Copy`;\n      }\n    }\n  }\n\n  return invoke<string>(\"models_duplicate\", { model: { ...model, name } });\n}\n\nexport async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(\n  patch: Partial<T> & Pick<T, \"model\">,\n): Promise<string> {\n  return invoke<string>(\"models_upsert\", { model: patch });\n}\n\nexport async function createWorkspaceModel<T extends Extract<AnyModel, { workspaceId: string }>>(\n  patch: Partial<T> & Pick<T, \"model\" | \"workspaceId\">,\n): Promise<string> {\n  return invoke<string>(\"models_upsert\", { model: patch });\n}\n\nexport function replaceModelsInStore<\n  M extends AnyModel[\"model\"],\n  T extends Extract<AnyModel, { model: M }>,\n>(model: M, models: T[]) {\n  const newModels: Record<string, T> = {};\n  for (const model of models) {\n    newModels[model.id] = model;\n  }\n\n  mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {\n    return {\n      ...prev,\n      [model]: newModels,\n    };\n  });\n}\n\nexport function mergeModelsInStore<\n  M extends AnyModel[\"model\"],\n  T extends Extract<AnyModel, { model: M }>,\n>(model: M, models: T[], filter?: (model: T) => boolean) {\n  mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => {\n    const existingModels = { ...prev[model] } as Record<string, T>;\n\n    // Merge in new models first\n    for (const m of models) {\n      existingModels[m.id] = m;\n    }\n\n    // Then filter out unwanted models\n    if (filter) {\n      for (const [id, m] of Object.entries(existingModels)) {\n        if (!filter(m)) {\n          delete existingModels[id];\n        }\n      }\n    }\n\n    return {\n      ...prev,\n      [model]: existingModels,\n    };\n  });\n}\n\nfunction shouldIgnoreModel({ model, updateSource }: ModelPayload) {\n  // Never ignore updates from non-user sources\n  if (updateSource.type !== \"window\") {\n    return false;\n  }\n\n  // Never ignore same-window updates\n  if (updateSource.label === getCurrentWebviewWindow().label) {\n    return false;\n  }\n\n  // Only sync models that belong to this workspace, if a workspace ID is present\n  if (\"workspaceId\" in model && model.workspaceId !== _activeWorkspaceId) {\n    return true;\n  }\n\n  if (model.model === \"key_value\" && model.namespace === \"no_sync\") {\n    return true;\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "crates/yaak-models/guest-js/types.ts",
    "content": "import { createStore } from \"jotai\";\nimport { AnyModel } from \"../bindings/gen_models\";\n\nexport type ExtractModel<T, M> = T extends { model: M } ? T : never;\nexport type ModelStoreData<T extends AnyModel = AnyModel> = {\n  [M in T[\"model\"]]: Record<string, Extract<T, { model: M }>>;\n};\nexport type JotaiStore = ReturnType<typeof createStore>;\n"
  },
  {
    "path": "crates/yaak-models/guest-js/util.ts",
    "content": "import { ModelStoreData } from \"./types\";\n\nexport function newStoreData(): ModelStoreData {\n  return {\n    cookie_jar: {},\n    environment: {},\n    folder: {},\n    graphql_introspection: {},\n    grpc_connection: {},\n    grpc_event: {},\n    grpc_request: {},\n    http_request: {},\n    http_response: {},\n    http_response_event: {},\n    key_value: {},\n    plugin: {},\n    settings: {},\n    sync_state: {},\n    websocket_connection: {},\n    websocket_event: {},\n    websocket_request: {},\n    workspace: {},\n    workspace_meta: {},\n  };\n}\n"
  },
  {
    "path": "crates/yaak-models/migrations/20230225181302_init.sql",
    "content": "CREATE TABLE key_values\n(\n    model      TEXT     DEFAULT 'key_value'       NOT NULL,\n    created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    deleted_at DATETIME,\n    namespace  TEXT                               NOT NULL,\n    key        TEXT                               NOT NULL,\n    value      TEXT                               NOT NULL,\n    PRIMARY KEY (namespace, key)\n);\n\nCREATE TABLE workspaces\n(\n    id          TEXT                               NOT NULL\n        PRIMARY KEY,\n    model       TEXT     DEFAULT 'workspace'       NOT NULL,\n    created_at  DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at  DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    deleted_at  DATETIME,\n    name        TEXT                               NOT NULL,\n    description TEXT                               NOT NULL\n);\n\nCREATE TABLE http_requests\n(\n    id           TEXT                               NOT NULL\n        PRIMARY KEY,\n    model        TEXT     DEFAULT 'http_request'    NOT NULL,\n    workspace_id TEXT                               NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    created_at   DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at   DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    deleted_at   DATETIME,\n    name         TEXT                               NOT NULL,\n    url          TEXT                               NOT NULL,\n    method       TEXT                               NOT NULL,\n    headers      TEXT                               NOT NULL,\n    body         TEXT,\n    body_type    TEXT\n);\n\nCREATE TABLE http_responses\n(\n    id            TEXT                               NOT NULL\n        PRIMARY KEY,\n    model         TEXT     DEFAULT 'http_response'   NOT NULL,\n    request_id    TEXT                               NOT NULL\n        REFERENCES http_requests\n            ON DELETE CASCADE,\n    workspace_id  TEXT                               NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    created_at    DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at    DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    deleted_at    DATETIME,\n    elapsed       INTEGER                            NOT NULL,\n    status        INTEGER                            NOT NULL,\n    status_reason TEXT,\n    url           TEXT                               NOT NULL,\n    body          TEXT                               NOT NULL,\n    headers       TEXT                               NOT NULL,\n    error         TEXT\n);\n"
  },
  {
    "path": "crates/yaak-models/migrations/20230319042610_sort-priority.sql",
    "content": "ALTER TABLE main.http_requests ADD COLUMN sort_priority REAL NOT NULL DEFAULT 0;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20230330143214_request-auth.sql",
    "content": "ALTER TABLE http_requests ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}';\nALTER TABLE http_requests ADD COLUMN authentication_type TEXT;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20230413232435_response-body-blob.sql",
    "content": "DELETE FROM main.http_responses;\nALTER TABLE http_responses DROP COLUMN body;\nALTER TABLE http_responses ADD COLUMN body BLOB;\nALTER TABLE http_responses ADD COLUMN body_path TEXT;\nALTER TABLE http_responses ADD COLUMN content_length INTEGER;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20231022205109_environments.sql",
    "content": "CREATE TABLE environments\n(\n    id          TEXT                               NOT NULL\n        PRIMARY KEY,\n    model       TEXT     DEFAULT 'workspace'       NOT NULL,\n    created_at  DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at  DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    deleted_at  DATETIME,\n    workspace_id TEXT                               NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    name        TEXT                               NOT NULL,\n    data        TEXT                               NOT NULL\n        DEFAULT '{}'\n);\n"
  },
  {
    "path": "crates/yaak-models/migrations/20231028161007_variables.sql",
    "content": "ALTER TABLE environments DROP COLUMN data;\nALTER TABLE environments ADD COLUMN variables DEFAULT '[]' NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20231103004111_workspace-variables.sql",
    "content": "ALTER TABLE workspaces ADD COLUMN variables DEFAULT '[]' NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20231103142807_folders.sql",
    "content": "CREATE TABLE folders\n(\n    id            TEXT                               NOT NULL\n        PRIMARY   KEY,\n    model         TEXT     DEFAULT 'folder'          NOT NULL,\n    created_at    DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at    DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    deleted_at    DATETIME,\n    workspace_id  TEXT                               NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    folder_id     TEXT                               NULL\n        REFERENCES folders\n            ON DELETE CASCADE,\n    name          TEXT                               NOT NULL,\n    sort_priority REAL     DEFAULT 0                 NOT NULL\n);\n\nALTER TABLE http_requests ADD COLUMN folder_id TEXT REFERENCES folders(id) ON DELETE CASCADE;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20231112180500_body_object.sql",
    "content": "-- Rename old column to backup name\nALTER TABLE http_requests\n    RENAME COLUMN body TO body_old;\n\n-- Create desired new body column\nALTER TABLE http_requests\n    ADD COLUMN body TEXT NOT NULL DEFAULT '{}';\n\n-- Copy data from old to new body, in new JSON format\nUPDATE http_requests\nSET body = CASE WHEN body_old IS NULL THEN '{}' ELSE JSON_OBJECT('text', body_old) END\nWHERE TRUE;\n\n-- Drop old column\nALTER TABLE http_requests\n    DROP COLUMN body_old;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20231113183810_url_params.sql",
    "content": "ALTER TABLE http_requests\n    ADD COLUMN url_parameters TEXT NOT NULL DEFAULT '[]';\n"
  },
  {
    "path": "crates/yaak-models/migrations/20231122055216_remove_body.sql",
    "content": "ALTER TABLE http_responses DROP COLUMN body;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20240111221224_settings.sql",
    "content": "CREATE TABLE settings\n(\n    id                    TEXT                               NOT NULL\n        PRIMARY KEY,\n    model                 TEXT     DEFAULT 'settings'        NOT NULL,\n    created_at            DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at            DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    follow_redirects      BOOLEAN  DEFAULT TRUE              NOT NULL,\n    validate_certificates BOOLEAN  DEFAULT TRUE              NOT NULL,\n    request_timeout       INTEGER  DEFAULT 0                 NOT NULL,\n    theme                 TEXT     DEFAULT 'default'         NOT NULL,\n    appearance            TEXT     DEFAULT 'system'          NOT NULL\n);\n"
  },
  {
    "path": "crates/yaak-models/migrations/20240115193751_workspace_settings.sql",
    "content": "-- Add existing request-related settings to workspace\nALTER TABLE workspaces ADD COLUMN setting_request_timeout INTEGER DEFAULT '0' NOT NULL;\nALTER TABLE workspaces ADD COLUMN setting_validate_certificates BOOLEAN DEFAULT TRUE NOT NULL;\nALTER TABLE workspaces ADD COLUMN setting_follow_redirects BOOLEAN DEFAULT TRUE NOT NULL;\n\n-- Remove old settings that used to be global\nALTER TABLE settings DROP COLUMN request_timeout;\nALTER TABLE settings DROP COLUMN follow_redirects;\nALTER TABLE settings DROP COLUMN validate_certificates;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20240118181105_channel_setting.sql",
    "content": "ALTER TABLE settings ADD COLUMN update_channel TEXT DEFAULT 'stable' NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20240127013915_cookies.sql",
    "content": "CREATE TABLE cookie_jars\n(\n    id           TEXT                               NOT NULL PRIMARY KEY,\n    model        TEXT     DEFAULT 'cookie_jar'      NOT NULL,\n    created_at   DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at   DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    name         TEXT                               NOT NULL,\n    cookies      TEXT     DEFAULT '[]'              NOT NULL,\n    workspace_id TEXT                               NOT NULL\n);\n"
  },
  {
    "path": "crates/yaak-models/migrations/20240128230717_more_response_attrs.sql",
    "content": "ALTER TABLE http_responses ADD COLUMN elapsed_headers INTEGER NOT NULL DEFAULT 0;\nALTER TABLE http_responses ADD COLUMN remote_addr TEXT;\nALTER TABLE http_responses ADD COLUMN version TEXT;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20240203164833_grpc.sql",
    "content": "CREATE TABLE grpc_requests\n(\n    id                  TEXT                                                    NOT NULL\n        PRIMARY KEY,\n    model               TEXT     DEFAULT 'grpc_request'                         NOT NULL,\n    workspace_id        TEXT                                                    NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    folder_id           TEXT                                                    NULL\n        REFERENCES folders\n            ON DELETE CASCADE,\n    created_at          DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,\n    updated_at          DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,\n    name                TEXT                                                    NOT NULL,\n    sort_priority       REAL                                                    NOT NULL,\n    url                 TEXT                                                    NOT NULL,\n    service             TEXT                                                    NULL,\n    method              TEXT                                                    NULL,\n    message             TEXT                                                    NOT NULL,\n    authentication      TEXT     DEFAULT '{}'                                   NOT NULL,\n    authentication_type TEXT                                                    NULL,\n    metadata            TEXT     DEFAULT '[]'                                   NOT NULL\n);\n\nCREATE TABLE grpc_connections\n(\n    id           TEXT                                                    NOT NULL\n        PRIMARY KEY,\n    model        TEXT     DEFAULT 'grpc_connection'                      NOT NULL,\n    workspace_id TEXT                                                    NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    request_id   TEXT                                                    NOT NULL\n        REFERENCES grpc_requests\n            ON DELETE CASCADE,\n    created_at   DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,\n    updated_at   DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,\n    url          TEXT                                                    NOT NULL,\n    service      TEXT                                                    NOT NULL,\n    method       TEXT                                                    NOT NULL,\n    status       INTEGER  DEFAULT -1                                     NOT NULL,\n    error        TEXT                                                    NULL,\n    elapsed      INTEGER  DEFAULT 0                                      NOT NULL,\n    trailers     TEXT     DEFAULT '{}'                                   NOT NULL\n);\n\nCREATE TABLE grpc_events\n(\n    id            TEXT                                                    NOT NULL\n        PRIMARY KEY,\n    model         TEXT     DEFAULT 'grpc_event'                           NOT NULL,\n    workspace_id  TEXT                                                    NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    request_id    TEXT                                                    NOT NULL\n        REFERENCES grpc_requests\n            ON DELETE CASCADE,\n    connection_id TEXT                                                    NOT NULL\n        REFERENCES grpc_connections\n            ON DELETE CASCADE,\n    created_at    DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,\n    updated_at    DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,\n    metadata      TEXT     DEFAULT '{}'                                   NOT NULL,\n    event_type    TEXT                                                    NOT NULL,\n    status        INTEGER                                                 NULL,\n    error         TEXT                                                    NULL,\n    content       TEXT                                                    NOT NULL\n);\n"
  },
  {
    "path": "crates/yaak-models/migrations/20240522031045_theme-settings.sql",
    "content": "ALTER TABLE settings\n    ADD COLUMN theme_dark TEXT DEFAULT 'yaak-dark' NOT NULL;\nALTER TABLE settings\n    ADD COLUMN theme_light TEXT DEFAULT 'yaak-light' NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20240529143147_more-settings.sql",
    "content": "ALTER TABLE settings ADD COLUMN interface_font_size INTEGER DEFAULT 15 NOT NULL;\nALTER TABLE settings ADD COLUMN interface_scale INTEGER DEFAULT 1 NOT NULL;\nALTER TABLE settings ADD COLUMN editor_font_size INTEGER DEFAULT 13 NOT NULL;\nALTER TABLE settings ADD COLUMN editor_soft_wrap BOOLEAN DEFAULT 1 NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20240607151115_open-workspace-setting.sql",
    "content": "ALTER TABLE settings ADD COLUMN open_workspace_new_window BOOLEAN NULL DEFAULT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20240814013812_fix-env-model.sql",
    "content": "ALTER TABLE environments DROP COLUMN model;\nALTER TABLE environments ADD COLUMN model TEXT DEFAULT 'environment';\n"
  },
  {
    "path": "crates/yaak-models/migrations/20240826184943_disable-telemetry.sql",
    "content": "ALTER TABLE settings ADD COLUMN telemetry BOOLEAN DEFAULT TRUE;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20240829131004_plugins.sql",
    "content": "CREATE TABLE plugins\n(\n    id         TEXT                               NOT NULL\n        PRIMARY KEY,\n    model      TEXT     DEFAULT 'plugin'          NOT NULL,\n    created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    checked_at DATETIME NULL,\n    enabled    BOOLEAN                            NOT NULL,\n    directory  TEXT NULL                          NOT NULL,\n    url        TEXT NULL\n);\n"
  },
  {
    "path": "crates/yaak-models/migrations/20241003134208_response-state.sql",
    "content": "ALTER TABLE http_responses\n    ADD COLUMN state TEXT DEFAULT 'closed' NOT NULL;\n\nALTER TABLE grpc_connections\n    ADD COLUMN state TEXT DEFAULT 'closed' NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20241012181547_proxy-setting.sql",
    "content": "ALTER TABLE settings ADD COLUMN proxy TEXT;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20241217204951_docs.sql",
    "content": "ALTER TABLE http_requests\n    ADD COLUMN description TEXT DEFAULT '' NOT NULL;\n\nALTER TABLE grpc_requests\n    ADD COLUMN description TEXT DEFAULT '' NOT NULL;\n\nALTER TABLE folders\n    ADD COLUMN description TEXT DEFAULT '' NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20241219140051_base-environments.sql",
    "content": "-- Add the new field\nALTER TABLE environments\n    ADD COLUMN environment_id TEXT REFERENCES environments (id) ON DELETE CASCADE;\n\n-- Create temporary column so we know which rows are meant to be base environments. We'll use this to update\n-- child environments to point to them.\nALTER TABLE environments\n    ADD COLUMN migrated_base_env BOOLEAN DEFAULT FALSE NOT NULL;\n\n-- Create a base environment for each workspace\nINSERT INTO environments (id, workspace_id, name, variables, migrated_base_env)\nSELECT (\n           -- This is the best way to generate a random string in SQLite, apparently\n           'ev_' || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1)\n           ),\n       workspaces.id,\n       'Global Variables',\n       variables,\n       TRUE\nFROM workspaces;\n\n-- Update all non-base environments to point to newly created base environments\nUPDATE environments\nSET environment_id = ( SELECT base_env.id\n                       FROM environments AS base_env\n                       WHERE base_env.workspace_id = environments.workspace_id\n                         AND base_env.migrated_base_env IS TRUE )\nWHERE migrated_base_env IS FALSE;\n\n-- Drop temporary column\nALTER TABLE environments\n    DROP COLUMN migrated_base_env;\n\n-- Drop the old variables column\n-- IMPORTANT: Skip to give the user the option to roll back to a previous app version. We can drop it once the migration working in the real world\n-- ALTER TABLE workspaces DROP COLUMN variables;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250102141937_sync.sql",
    "content": "ALTER TABLE workspaces\n    ADD COLUMN setting_sync_dir TEXT;\n\nCREATE TABLE sync_states\n(\n    id           TEXT                               NOT NULL\n        PRIMARY KEY,\n    model        TEXT     DEFAULT 'sync_state'      NOT NULL,\n    workspace_id TEXT                               NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    created_at   DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at   DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    flushed_at   DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    checksum     TEXT                               NOT NULL,\n    model_id     TEXT                               NOT NULL,\n    sync_dir     TEXT                               NOT NULL,\n    rel_path     TEXT                               NOT NULL,\n\n    UNIQUE (workspace_id, model_id)\n);\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250108035425_editor-keymap.sql",
    "content": "ALTER TABLE settings\n    ADD COLUMN editor_keymap TEXT DEFAULT 'codemirror' NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250108205117_workspace-meta.sql",
    "content": "CREATE TABLE workspace_metas\n(\n    id               TEXT                               NOT NULL\n        PRIMARY KEY,\n    model            TEXT     DEFAULT 'workspace_meta'  NOT NULL,\n    workspace_id     TEXT                               NOT NULL\n        REFERENCES workspaces ON DELETE CASCADE,\n    created_at       DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at       DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    setting_sync_dir TEXT\n);\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250114160022_remove-workspace-sync-setting.sql",
    "content": "-- This setting was moved to the new workspace_metas table\nALTER TABLE workspaces DROP COLUMN setting_sync_dir;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250123192023_plugin-kv.sql",
    "content": "CREATE TABLE plugin_key_values\n(\n    model       TEXT     DEFAULT 'plugin_key_value' NOT NULL,\n    created_at  DATETIME DEFAULT CURRENT_TIMESTAMP  NOT NULL,\n    updated_at  DATETIME DEFAULT CURRENT_TIMESTAMP  NOT NULL,\n    deleted_at  DATETIME,\n    plugin_name TEXT                                NOT NULL,\n    key         TEXT                                NOT NULL,\n    value       TEXT                                NOT NULL,\n    PRIMARY KEY (plugin_name, key)\n);\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250128155623_websockets.sql",
    "content": "CREATE TABLE websocket_requests\n(\n    id                  TEXT                                 NOT NULL\n        PRIMARY KEY,\n    model               TEXT     DEFAULT 'websocket_request' NOT NULL,\n    workspace_id        TEXT                                 NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    folder_id           TEXT\n        REFERENCES folders\n            ON DELETE CASCADE,\n    created_at          DATETIME DEFAULT CURRENT_TIMESTAMP   NOT NULL,\n    updated_at          DATETIME DEFAULT CURRENT_TIMESTAMP   NOT NULL,\n    deleted_at          DATETIME,\n    authentication      TEXT     DEFAULT '{}'                NOT NULL,\n    authentication_type TEXT,\n    description         TEXT                                 NOT NULL,\n    name                TEXT                                 NOT NULL,\n    url                 TEXT                                 NOT NULL,\n    headers             TEXT                                 NOT NULL,\n    message             TEXT                                 NOT NULL,\n    sort_priority       REAL                                 NOT NULL,\n    url_parameters      TEXT     DEFAULT '[]'                NOT NULL\n);\n\nCREATE TABLE websocket_connections\n(\n    id           TEXT                                    NOT NULL\n        PRIMARY KEY,\n    model        TEXT     DEFAULT 'websocket_connection' NOT NULL,\n    workspace_id TEXT                                    NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    request_id   TEXT                                    NOT NULL\n        REFERENCES websocket_requests\n            ON DELETE CASCADE,\n    created_at   DATETIME DEFAULT CURRENT_TIMESTAMP      NOT NULL,\n    updated_at   DATETIME DEFAULT CURRENT_TIMESTAMP      NOT NULL,\n    url          TEXT                                    NOT NULL,\n    state        TEXT                                    NOT NULL,\n    status       INTEGER  DEFAULT -1                     NOT NULL,\n    error        TEXT                                    NULL,\n    elapsed      INTEGER  DEFAULT 0                      NOT NULL,\n    headers      TEXT     DEFAULT '{}'                   NOT NULL\n);\n\nCREATE TABLE websocket_events\n(\n    id            TEXT                                                    NOT NULL\n        PRIMARY KEY,\n    model         TEXT     DEFAULT 'websocket_event'                      NOT NULL,\n    workspace_id  TEXT                                                    NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    request_id    TEXT                                                    NOT NULL\n        REFERENCES websocket_requests\n            ON DELETE CASCADE,\n    connection_id TEXT                                                    NOT NULL\n        REFERENCES websocket_connections\n            ON DELETE CASCADE,\n    created_at    DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,\n    updated_at    DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,\n    is_server     BOOLEAN                                                 NOT NULL,\n    message_type  TEXT                                                    NOT NULL,\n    message       BLOB                                                    NOT NULL\n);\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250302041707_hide-window-controls.sql",
    "content": "ALTER TABLE settings\n    ADD COLUMN hide_window_controls BOOLEAN DEFAULT FALSE NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250326193143_key-value-id.sql",
    "content": "-- 1. Create the new table with `id` as the primary key\nCREATE TABLE key_values_new\n(\n    id         TEXT PRIMARY KEY,\n    model      TEXT     DEFAULT 'key_value'       NOT NULL,\n    created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    deleted_at DATETIME,\n    namespace  TEXT                               NOT NULL,\n    key        TEXT                               NOT NULL,\n    value      TEXT                               NOT NULL\n);\n\n-- 2. Copy data from the old table\nINSERT INTO key_values_new (id, model, created_at, updated_at, deleted_at, namespace, key, value)\nSELECT (\n           -- This is the best way to generate a random string in SQLite, apparently\n           'kv_' || SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1) ||\n           SUBSTR('abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23457789', (ABS(RANDOM()) % 57) + 1, 1)\n           ) AS id,\n       model,\n       created_at,\n       updated_at,\n       deleted_at,\n       namespace,\n       key,\n       value\nFROM key_values;\n\n-- 3. Drop the old table\nDROP TABLE key_values;\n\n-- 4. Rename the new table\nALTER TABLE key_values_new\n    RENAME TO key_values;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250401122407_encrypted-key.sql",
    "content": "ALTER TABLE workspace_metas ADD COLUMN encryption_key TEXT NULL DEFAULT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250402144842_encryption-key-challenge.sql",
    "content": "ALTER TABLE workspaces ADD COLUMN encryption_key_challenge TEXT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250424152740_remove-fks.sql",
    "content": "-- NOTE: SQLite does not support dropping foreign keys, so we need to create new\n-- tables and copy data instead. To prevent cascade deletes from wrecking stuff,\n-- we start with the leaf tables and finish with the parent tables (eg. folder).\n\n----------------------------\n-- Remove http request FK --\n----------------------------\n\nCREATE TABLE http_requests_dg_tmp\n(\n    id                  TEXT                               NOT NULL\n        PRIMARY KEY,\n    model               TEXT     DEFAULT 'http_request'    NOT NULL,\n    workspace_id        TEXT                               NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    created_at          DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at          DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    deleted_at          DATETIME,\n    name                TEXT                               NOT NULL,\n    url                 TEXT                               NOT NULL,\n    method              TEXT                               NOT NULL,\n    headers             TEXT                               NOT NULL,\n    body_type           TEXT,\n    sort_priority       REAL     DEFAULT 0                 NOT NULL,\n    authentication      TEXT     DEFAULT '{}'              NOT NULL,\n    authentication_type TEXT,\n    folder_id           TEXT,\n    body                TEXT     DEFAULT '{}'              NOT NULL,\n    url_parameters      TEXT     DEFAULT '[]'              NOT NULL,\n    description         TEXT     DEFAULT ''                NOT NULL\n);\n\nINSERT INTO http_requests_dg_tmp(id, model, workspace_id, created_at, updated_at, deleted_at, name, url, method,\n                                 headers, body_type, sort_priority, authentication, authentication_type, folder_id,\n                                 body, url_parameters, description)\nSELECT id,\n       model,\n       workspace_id,\n       created_at,\n       updated_at,\n       deleted_at,\n       name,\n       url,\n       method,\n       headers,\n       body_type,\n       sort_priority,\n       authentication,\n       authentication_type,\n       folder_id,\n       body,\n       url_parameters,\n       description\nFROM http_requests;\n\nDROP TABLE http_requests;\n\nALTER TABLE http_requests_dg_tmp\n    RENAME TO http_requests;\n\n----------------------------\n-- Remove grpc request FK --\n----------------------------\n\nCREATE TABLE grpc_requests_dg_tmp\n(\n    id                  TEXT                                                    NOT NULL\n        PRIMARY KEY,\n    model               TEXT     DEFAULT 'grpc_request'                         NOT NULL,\n    workspace_id        TEXT                                                    NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    folder_id           TEXT,\n    created_at          DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,\n    updated_at          DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,\n    name                TEXT                                                    NOT NULL,\n    sort_priority       REAL                                                    NOT NULL,\n    url                 TEXT                                                    NOT NULL,\n    service             TEXT,\n    method              TEXT,\n    message             TEXT                                                    NOT NULL,\n    authentication      TEXT     DEFAULT '{}'                                   NOT NULL,\n    authentication_type TEXT,\n    metadata            TEXT     DEFAULT '[]'                                   NOT NULL,\n    description         TEXT     DEFAULT ''                                     NOT NULL\n);\n\nINSERT INTO grpc_requests_dg_tmp(id, model, workspace_id, folder_id, created_at, updated_at, name, sort_priority, url,\n                                 service, method, message, authentication, authentication_type, metadata, description)\nSELECT id,\n       model,\n       workspace_id,\n       folder_id,\n       created_at,\n       updated_at,\n       name,\n       sort_priority,\n       url,\n       service,\n       method,\n       message,\n       authentication,\n       authentication_type,\n       metadata,\n       description\nFROM grpc_requests;\n\nDROP TABLE grpc_requests;\n\nALTER TABLE grpc_requests_dg_tmp\n    RENAME TO grpc_requests;\n\n---------------------------------\n-- Remove websocket request FK --\n---------------------------------\n\nCREATE TABLE websocket_requests_dg_tmp\n(\n    id                  TEXT                                 NOT NULL\n        PRIMARY KEY,\n    model               TEXT     DEFAULT 'websocket_request' NOT NULL,\n    workspace_id        TEXT                                 NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    folder_id           TEXT,\n    created_at          DATETIME DEFAULT CURRENT_TIMESTAMP   NOT NULL,\n    updated_at          DATETIME DEFAULT CURRENT_TIMESTAMP   NOT NULL,\n    deleted_at          DATETIME,\n    authentication      TEXT     DEFAULT '{}'                NOT NULL,\n    authentication_type TEXT,\n    description         TEXT                                 NOT NULL,\n    name                TEXT                                 NOT NULL,\n    url                 TEXT                                 NOT NULL,\n    headers             TEXT                                 NOT NULL,\n    message             TEXT                                 NOT NULL,\n    sort_priority       REAL                                 NOT NULL,\n    url_parameters      TEXT     DEFAULT '[]'                NOT NULL\n);\n\nINSERT INTO websocket_requests_dg_tmp(id, model, workspace_id, folder_id, created_at, updated_at, deleted_at,\n                                      authentication, authentication_type, description, name, url, headers, message,\n                                      sort_priority, url_parameters)\nSELECT id,\n       model,\n       workspace_id,\n       folder_id,\n       created_at,\n       updated_at,\n       deleted_at,\n       authentication,\n       authentication_type,\n       description,\n       name,\n       url,\n       headers,\n       message,\n       sort_priority,\n       url_parameters\nFROM websocket_requests;\n\nDROP TABLE websocket_requests;\n\nALTER TABLE websocket_requests_dg_tmp\n    RENAME TO websocket_requests;\n\n---------------------------\n-- Remove environment FK --\n---------------------------\n\nCREATE TABLE environments_dg_tmp\n(\n    id             TEXT                               NOT NULL\n        PRIMARY KEY,\n    created_at     DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at     DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    deleted_at     DATETIME,\n    workspace_id   TEXT                               NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    name           TEXT                               NOT NULL,\n    variables               DEFAULT '[]'              NOT NULL,\n    model          TEXT     DEFAULT 'environment',\n    environment_id TEXT\n);\n\nINSERT INTO environments_dg_tmp(id, created_at, updated_at, deleted_at, workspace_id, name, variables, model,\n                                environment_id)\nSELECT id,\n       created_at,\n       updated_at,\n       deleted_at,\n       workspace_id,\n       name,\n       variables,\n       model,\n       environment_id\nFROM environments;\n\nDROP TABLE environments;\n\nALTER TABLE environments_dg_tmp\n    RENAME TO environments;\n\n----------------------\n-- Remove folder FK --\n----------------------\n\nCREATE TABLE folders_dg_tmp\n(\n    id            TEXT                               NOT NULL\n        PRIMARY KEY,\n    model         TEXT     DEFAULT 'folder'          NOT NULL,\n    created_at    DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at    DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    deleted_at    DATETIME,\n    workspace_id  TEXT                               NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    folder_id     TEXT,\n    name          TEXT                               NOT NULL,\n    sort_priority REAL     DEFAULT 0                 NOT NULL,\n    description   TEXT     DEFAULT ''                NOT NULL\n);\n\nINSERT INTO folders_dg_tmp(id, model, created_at, updated_at, deleted_at, workspace_id, folder_id, name, sort_priority,\n                           description)\nSELECT id,\n       model,\n       created_at,\n       updated_at,\n       deleted_at,\n       workspace_id,\n       folder_id,\n       name,\n       sort_priority,\n       description\nFROM folders;\n\nDROP TABLE folders;\n\nALTER TABLE folders_dg_tmp\n    RENAME TO folders;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250507140702_remove-ev-sync-states.sql",
    "content": "-- There used to be sync code that skipped over environments because we didn't\n-- want to sync potentially insecure data. With encryption, it is now possible\n-- to sync environments securely. However, there were already sync states in the\n-- DB that marked environments as \"Synced\". Running the sync code on these envs\n-- would mark them as deleted by FS (exist in SyncState but not on FS).\n--\n-- To undo this mess, we have this migration to delete all environment-related\n-- sync states so we can sync from a clean slate.\nDELETE\nFROM sync_states\nWHERE model_id LIKE 'ev_%';\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250508161145_public-environments.sql",
    "content": "-- Add a public column to represent whether an environment can be shared or exported\nALTER TABLE environments\n    ADD COLUMN public BOOLEAN DEFAULT FALSE;\n\n-- Add a base column to represent whether an environment is a base or sub environment. We used to\n-- do this with environment_id, but we need a more flexible solution now that envs can be optionally\n-- synced. E.g., it's now possible to only import a sub environment from a different client without\n-- its base environment \"parent.\"\nALTER TABLE environments\n    ADD COLUMN base BOOLEAN DEFAULT FALSE;\n\n-- SQLite doesn't support dynamic default values, so we update `base` based on the value of\n-- environment_id.\nUPDATE environments\nSET base = TRUE\nWHERE environment_id IS NULL;\n\n-- Finally, we drop the old `environment_id` column that will no longer be used\nALTER TABLE environments\n    DROP COLUMN environment_id;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250516182745_default-attrs.sql",
    "content": "-- Auth\nALTER TABLE workspaces\n    ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}';\nALTER TABLE folders\n    ADD COLUMN authentication TEXT NOT NULL DEFAULT '{}';\nALTER TABLE workspaces\n    ADD COLUMN authentication_type TEXT;\nALTER TABLE folders\n    ADD COLUMN authentication_type TEXT;\n\n-- Headers\nALTER TABLE workspaces\n    ADD COLUMN headers TEXT NOT NULL DEFAULT '[]';\nALTER TABLE folders\n    ADD COLUMN headers TEXT NOT NULL DEFAULT '[]';\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250530174021_graphql-introspection.sql",
    "content": "-- Clean up old key/values that are no longer used\nDELETE\nFROM key_values\nWHERE key LIKE 'graphql_introspection::%';\n\nCREATE TABLE graphql_introspections\n(\n\n    id           TEXT                                     NOT NULL\n        PRIMARY KEY,\n    model        TEXT     DEFAULT 'graphql_introspection' NOT NULL,\n    created_at   DATETIME DEFAULT CURRENT_TIMESTAMP       NOT NULL,\n    updated_at   DATETIME DEFAULT CURRENT_TIMESTAMP       NOT NULL,\n    workspace_id TEXT                                     NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    request_id   TEXT                                     NULL\n        REFERENCES http_requests\n            ON DELETE CASCADE,\n    content      TEXT                                     NULL\n);\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250531193722_sync-state-index.sql",
    "content": "-- Add sync_dir to the unique index, or else it will fail if the user disables sync\n-- and re-enables it for a different directory.\n\n-- Step 1: Rename the existing table\nALTER TABLE sync_states\n    RENAME TO sync_states_old;\n\n-- Step 2: Create the new table with the updated unique constraint\nCREATE TABLE sync_states\n(\n    id           TEXT                               NOT NULL PRIMARY KEY,\n    model        TEXT     DEFAULT 'sync_state'      NOT NULL,\n    workspace_id TEXT                               NOT NULL\n        REFERENCES workspaces ON DELETE CASCADE,\n    created_at   DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at   DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    flushed_at   DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    checksum     TEXT                               NOT NULL,\n    model_id     TEXT                               NOT NULL,\n    sync_dir     TEXT                               NOT NULL,\n    rel_path     TEXT                               NOT NULL\n);\n\nCREATE UNIQUE INDEX idx_sync_states_unique\n    ON sync_states (workspace_id, model_id, sync_dir);\n\n-- Step 3: Copy the data\nINSERT INTO sync_states (id, model, workspace_id, created_at, updated_at,\n                         flushed_at, checksum, model_id, sync_dir, rel_path)\nSELECT id,\n       model,\n       workspace_id,\n       created_at,\n       updated_at,\n       flushed_at,\n       checksum,\n       model_id,\n       sync_dir,\n       rel_path\nFROM sync_states_old;\n\n-- Step 4: Drop the old table\nDROP TABLE sync_states_old;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250604102922_colored-methods-setting.sql",
    "content": "ALTER TABLE settings\n    ADD COLUMN colored_methods BOOLEAN DEFAULT FALSE;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250608150053_font-settings.sql",
    "content": "ALTER TABLE settings ADD COLUMN interface_font TEXT;\nALTER TABLE settings ADD COLUMN editor_font TEXT;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250611120000_environment-color.sql",
    "content": "ALTER TABLE environments ADD COLUMN color TEXT; "
  },
  {
    "path": "crates/yaak-models/migrations/20250727190746_autoupdate_setting.sql",
    "content": "ALTER TABLE settings ADD COLUMN autoupdate BOOLEAN DEFAULT true NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250918141129_request-folder-environments.sql",
    "content": "-- Create temporary table for migration\nCREATE TABLE environments__new\n(\n    id           TEXT                               NOT NULL PRIMARY KEY,\n    created_at   DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    updated_at   DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    deleted_at   DATETIME,\n    workspace_id TEXT                               NOT NULL\n        REFERENCES workspaces ON DELETE CASCADE,\n\n    name         TEXT                               NOT NULL,\n    variables    TEXT     DEFAULT '[]'              NOT NULL,\n    model        TEXT     DEFAULT 'environment',\n    public       BOOLEAN  DEFAULT FALSE,\n    color        TEXT,\n\n    -- NEW\n    parent_model TEXT     DEFAULT 'workspace'       NOT NULL,\n    parent_id    TEXT\n);\n\n-- Backfill the data from the old table\n--    - base=1  -> (workspace, NULL)\n--    - base=0  -> (environment, id_of_workspace_base)  (fallback to workspace,NULL if none)\nINSERT INTO environments__new\n(id, created_at, updated_at, deleted_at, workspace_id, name, variables, model, public, color, parent_model, parent_id)\nSELECT\n    e.id,\n    e.created_at,\n    e.updated_at,\n    e.deleted_at,\n    e.workspace_id,\n    e.name,\n    e.variables,\n    e.model,\n    e.public,\n    e.color,\n    CASE\n        WHEN e.base = 1 THEN 'workspace'\n        WHEN (\n                 SELECT COUNT(1)\n                 FROM environments b\n                 WHERE b.workspace_id = e.workspace_id AND b.base = 1\n             ) > 0 THEN 'environment'\n        ELSE 'workspace'\n        END AS parent_model,\n    CASE\n        WHEN e.base = 1 THEN NULL\n        ELSE (\n            SELECT b.id\n            FROM environments b\n            WHERE b.workspace_id = e.workspace_id AND b.base = 1\n            ORDER BY b.created_at ASC, b.id ASC\n            LIMIT 1\n        )\n        END AS parent_id\nFROM environments e;\n\n-- Move everything to the new table\nDROP TABLE environments;\nALTER TABLE environments__new\n    RENAME TO environments;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20250929132954_dismiss-license-badge.sql",
    "content": "ALTER TABLE settings\n    ADD COLUMN hide_license_badge BOOLEAN DEFAULT FALSE;\n\n-- 2. Backfill based on old JSON\nUPDATE settings\nSET hide_license_badge = 1\nWHERE EXISTS ( SELECT 1\n               FROM key_values kv\n               WHERE kv.key = 'license_confirmation'\n                 AND JSON_EXTRACT(kv.value, '$.confirmedPersonalUse') = TRUE );\n"
  },
  {
    "path": "crates/yaak-models/migrations/20251001082054_auto-download.sql",
    "content": "ALTER TABLE settings\n    ADD COLUMN auto_download_updates BOOLEAN DEFAULT TRUE;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20251028060300_check_notifications_setting.sql",
    "content": "ALTER TABLE settings ADD COLUMN check_notifications BOOLEAN DEFAULT true NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20251029062024_aws-auth-name.sql",
    "content": "UPDATE http_requests\nSET authentication_type = 'awsv4'\nWHERE authentication_type = 'auth-aws-sig-v4';\n\nUPDATE folders\nSET authentication_type = 'awsv4'\nWHERE authentication_type = 'auth-aws-sig-v4';\n\nUPDATE workspaces\nSET authentication_type = 'awsv4'\nWHERE authentication_type = 'auth-aws-sig-v4';\n"
  },
  {
    "path": "crates/yaak-models/migrations/20251031070515_environment-sort-priority.sql",
    "content": "ALTER TABLE environments\n    ADD COLUMN sort_priority REAL DEFAULT 0 NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20251202080000_use-native-titlebar.sql",
    "content": "-- Add a setting to force native window title bar / controls\nALTER TABLE settings\n    ADD COLUMN use_native_titlebar BOOLEAN DEFAULT FALSE NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20251209000000_client-certificates.sql",
    "content": "ALTER TABLE settings ADD COLUMN client_certificates TEXT DEFAULT '[]' NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20251219074602_default-workspace-headers.sql",
    "content": "-- Add default User-Agent header to workspaces that don't already have one (case-insensitive check)\nUPDATE workspaces\nSET headers = json_insert(headers, '$[#]', json('{\"enabled\":true,\"name\":\"User-Agent\",\"value\":\"yaak\"}'))\nWHERE NOT EXISTS (\n    SELECT 1 FROM json_each(workspaces.headers)\n    WHERE LOWER(json_extract(value, '$.name')) = 'user-agent'\n);\n\n-- Add default Accept header to workspaces that don't already have one (case-insensitive check)\nUPDATE workspaces\nSET headers = json_insert(headers, '$[#]', json('{\"enabled\":true,\"name\":\"Accept\",\"value\":\"*/*\"}'))\nWHERE NOT EXISTS (\n    SELECT 1 FROM json_each(workspaces.headers)\n    WHERE LOWER(json_extract(value, '$.name')) = 'accept'\n);\n"
  },
  {
    "path": "crates/yaak-models/migrations/20251220000000_response-request-headers.sql",
    "content": "-- Add request_headers and content_length_compressed columns to http_responses table\nALTER TABLE http_responses ADD COLUMN request_headers TEXT NOT NULL DEFAULT '[]';\nALTER TABLE http_responses ADD COLUMN content_length_compressed INTEGER;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20251221000000_http-response-events.sql",
    "content": "CREATE TABLE http_response_events\n(\n    id           TEXT                                                    NOT NULL\n        PRIMARY KEY,\n    model        TEXT     DEFAULT 'http_response_event'                  NOT NULL,\n    workspace_id TEXT                                                    NOT NULL\n        REFERENCES workspaces\n            ON DELETE CASCADE,\n    response_id  TEXT                                                    NOT NULL\n        REFERENCES http_responses\n            ON DELETE CASCADE,\n    created_at   DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,\n    updated_at   DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL,\n    event        TEXT                                                    NOT NULL\n);\n"
  },
  {
    "path": "crates/yaak-models/migrations/20251221100000_request-content-length.sql",
    "content": "ALTER TABLE http_responses\n    ADD COLUMN request_content_length INTEGER;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20260104000000_hotkeys.sql",
    "content": "ALTER TABLE settings ADD COLUMN hotkeys TEXT DEFAULT '{}' NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20260111000000_dns-timing.sql",
    "content": "-- Add DNS resolution timing to http_responses\nALTER TABLE http_responses ADD COLUMN elapsed_dns INTEGER DEFAULT 0 NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20260112000000_dns-overrides.sql",
    "content": "-- Add DNS overrides setting to workspaces\nALTER TABLE workspaces ADD COLUMN setting_dns_overrides TEXT DEFAULT '[]' NOT NULL;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20260119045146_remove-default-workspace-headers.sql",
    "content": "-- Filter out headers that match the hardcoded defaults (User-Agent: yaak, Accept: */*),\n-- keeping any other custom headers the user may have added.\nUPDATE workspaces\nSET headers = (\n    SELECT json_group_array(json(value))\n    FROM json_each(headers)\n    WHERE NOT (\n        (LOWER(json_extract(value, '$.name')) = 'user-agent' AND json_extract(value, '$.value') = 'yaak')\n        OR (LOWER(json_extract(value, '$.name')) = 'accept' AND json_extract(value, '$.value') = '*/*')\n    )\n)\nWHERE json_array_length(headers) > 0;\n"
  },
  {
    "path": "crates/yaak-models/migrations/20260216000000_model-changes.sql",
    "content": "CREATE TABLE model_changes\n(\n    id            INTEGER PRIMARY KEY AUTOINCREMENT,\n    model         TEXT                                                    NOT NULL,\n    model_id      TEXT                                                    NOT NULL,\n    change        TEXT                                                    NOT NULL,\n    update_source TEXT                                                    NOT NULL,\n    payload       TEXT                                                    NOT NULL,\n    created_at    DATETIME DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) NOT NULL\n);\n\nCREATE INDEX idx_model_changes_created_at ON model_changes (created_at);\n"
  },
  {
    "path": "crates/yaak-models/migrations/20260217000000_remove-legacy-faker-plugin.sql",
    "content": "-- Remove stale plugin rows left over from the brief period when faker shipped as bundled.\nDELETE FROM plugins\nWHERE directory LIKE '%template-function-faker';\n"
  },
  {
    "path": "crates/yaak-models/migrations/20260301000000_plugin-source-and-unique-directory.sql",
    "content": "ALTER TABLE plugins\n    ADD COLUMN source TEXT DEFAULT 'filesystem' NOT NULL;\n\n-- Existing registry installs have a URL; classify them first.\nUPDATE plugins\nSET source = 'registry'\nWHERE url IS NOT NULL;\n\n-- Best-effort bundled backfill for legacy rows.\nUPDATE plugins\nSET source = 'bundled'\nWHERE source = 'filesystem'\n  AND (\n    -- Normalize separators so this also works for Windows paths.\n    replace(directory, '\\', '/') LIKE '%/vendored/plugins/%'\n        OR replace(directory, '\\', '/') LIKE '%/vendored-plugins/%'\n    );\n\n-- Keep one row per exact directory before adding uniqueness.\n-- Tie-break by recency.\nWITH ranked AS (SELECT id,\n                       ROW_NUMBER() OVER (\n                           PARTITION BY directory\n                           ORDER BY updated_at DESC,\n                               created_at DESC\n                           ) AS row_num\n                FROM plugins)\nDELETE\nFROM plugins\nWHERE id IN (SELECT id FROM ranked WHERE row_num > 1);\n\nCREATE UNIQUE INDEX IF NOT EXISTS idx_plugins_directory_unique\n    ON plugins (directory);\n"
  },
  {
    "path": "crates/yaak-models/package.json",
    "content": "{\n  \"name\": \"@yaakapp-internal/models\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"guest-js/index.ts\"\n}\n"
  },
  {
    "path": "crates/yaak-models/src/blob_manager.rs",
    "content": "use crate::error::Result;\nuse crate::util::generate_prefixed_id;\nuse include_dir::{Dir, include_dir};\nuse log::{debug, info};\nuse r2d2::Pool;\nuse r2d2_sqlite::SqliteConnectionManager;\nuse rusqlite::{OptionalExtension, params};\nuse std::sync::{Arc, Mutex};\n\nstatic BLOB_MIGRATIONS_DIR: Dir = include_dir!(\"$CARGO_MANIFEST_DIR/blob_migrations\");\n\n/// A chunk of body data stored in the blob database.\n#[derive(Debug, Clone)]\npub struct BodyChunk {\n    pub id: String,\n    pub body_id: String,\n    pub chunk_index: i32,\n    pub data: Vec<u8>,\n}\n\nimpl BodyChunk {\n    pub fn new(body_id: impl Into<String>, chunk_index: i32, data: Vec<u8>) -> Self {\n        Self { id: generate_prefixed_id(\"bc\"), body_id: body_id.into(), chunk_index, data }\n    }\n}\n\n/// Manages the blob database connection pool.\n#[derive(Debug, Clone)]\npub struct BlobManager {\n    pool: Arc<Mutex<Pool<SqliteConnectionManager>>>,\n}\n\nimpl BlobManager {\n    pub fn new(pool: Pool<SqliteConnectionManager>) -> Self {\n        Self { pool: Arc::new(Mutex::new(pool)) }\n    }\n\n    pub fn connect(&self) -> BlobContext {\n        let conn = self\n            .pool\n            .lock()\n            .expect(\"Failed to gain lock on blob DB\")\n            .get()\n            .expect(\"Failed to get blob DB connection from pool\");\n        BlobContext { conn }\n    }\n}\n\n/// Context for blob database operations.\npub struct BlobContext {\n    conn: r2d2::PooledConnection<SqliteConnectionManager>,\n}\n\nimpl BlobContext {\n    /// Insert a single chunk.\n    pub fn insert_chunk(&self, chunk: &BodyChunk) -> Result<()> {\n        self.conn.execute(\n            \"INSERT INTO body_chunks (id, body_id, chunk_index, data) VALUES (?1, ?2, ?3, ?4)\",\n            params![chunk.id, chunk.body_id, chunk.chunk_index, chunk.data],\n        )?;\n        Ok(())\n    }\n\n    /// Get all chunks for a body, ordered by chunk_index.\n    pub fn get_chunks(&self, body_id: &str) -> Result<Vec<BodyChunk>> {\n        let mut stmt = self.conn.prepare(\n            \"SELECT id, body_id, chunk_index, data FROM body_chunks\n             WHERE body_id = ?1 ORDER BY chunk_index ASC\",\n        )?;\n\n        let chunks = stmt\n            .query_map(params![body_id], |row| {\n                Ok(BodyChunk {\n                    id: row.get(0)?,\n                    body_id: row.get(1)?,\n                    chunk_index: row.get(2)?,\n                    data: row.get(3)?,\n                })\n            })?\n            .collect::<std::result::Result<Vec<_>, _>>()?;\n\n        Ok(chunks)\n    }\n\n    /// Delete all chunks for a body.\n    pub fn delete_chunks(&self, body_id: &str) -> Result<()> {\n        self.conn.execute(\"DELETE FROM body_chunks WHERE body_id = ?1\", params![body_id])?;\n        Ok(())\n    }\n\n    /// Delete all chunks matching a body_id prefix (e.g., \"rs_abc123.%\" to delete all bodies for a response).\n    pub fn delete_chunks_like(&self, body_id_prefix: &str) -> Result<()> {\n        self.conn\n            .execute(\"DELETE FROM body_chunks WHERE body_id LIKE ?1\", params![body_id_prefix])?;\n        Ok(())\n    }\n}\n\n/// Get total size of a body without loading data.\nimpl BlobContext {\n    pub fn get_body_size(&self, body_id: &str) -> Result<usize> {\n        let size: i64 = self\n            .conn\n            .query_row(\n                \"SELECT COALESCE(SUM(LENGTH(data)), 0) FROM body_chunks WHERE body_id = ?1\",\n                params![body_id],\n                |row| row.get(0),\n            )\n            .unwrap_or(0);\n        Ok(size as usize)\n    }\n\n    /// Check if a body exists.\n    pub fn body_exists(&self, body_id: &str) -> Result<bool> {\n        let count: i64 = self\n            .conn\n            .query_row(\n                \"SELECT COUNT(*) FROM body_chunks WHERE body_id = ?1\",\n                params![body_id],\n                |row| row.get(0),\n            )\n            .unwrap_or(0);\n        Ok(count > 0)\n    }\n}\n\n/// Run migrations for the blob database.\npub fn migrate_blob_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {\n    info!(\"Running blob database migrations\");\n\n    // Create migrations tracking table\n    pool.get()?.execute(\n        \"CREATE TABLE IF NOT EXISTS _blob_migrations (\n            version     TEXT PRIMARY KEY,\n            description TEXT NOT NULL,\n            applied_at  DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL\n        )\",\n        [],\n    )?;\n\n    // Read and sort all .sql files\n    let mut entries: Vec<_> = BLOB_MIGRATIONS_DIR\n        .entries()\n        .iter()\n        .filter(|e| e.path().extension().map(|ext| ext == \"sql\").unwrap_or(false))\n        .collect();\n\n    entries.sort_by_key(|e| e.path());\n\n    let mut ran_migrations = 0;\n    for entry in &entries {\n        let filename = entry.path().file_name().unwrap().to_str().unwrap();\n        let version = filename.split('_').next().unwrap();\n\n        // Check if already applied\n        let already_applied: Option<i64> = pool\n            .get()?\n            .query_row(\"SELECT 1 FROM _blob_migrations WHERE version = ?\", [version], |r| r.get(0))\n            .optional()?;\n\n        if already_applied.is_some() {\n            debug!(\"Skipping already applied blob migration: {}\", filename);\n            continue;\n        }\n\n        let sql =\n            entry.as_file().unwrap().contents_utf8().expect(\"Failed to read blob migration file\");\n\n        info!(\"Applying blob migration: {}\", filename);\n        let conn = pool.get()?;\n        conn.execute_batch(sql)?;\n\n        // Record migration\n        conn.execute(\n            \"INSERT INTO _blob_migrations (version, description) VALUES (?, ?)\",\n            params![version, filename],\n        )?;\n\n        ran_migrations += 1;\n    }\n\n    if ran_migrations == 0 {\n        info!(\"No blob migrations to run\");\n    } else {\n        info!(\"Ran {} blob migration(s)\", ran_migrations);\n    }\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn create_test_pool() -> Pool<SqliteConnectionManager> {\n        let manager = SqliteConnectionManager::memory();\n        let pool = Pool::builder().max_size(1).build(manager).unwrap();\n        migrate_blob_db(&pool).unwrap();\n        pool\n    }\n\n    #[test]\n    fn test_insert_and_get_chunks() {\n        let pool = create_test_pool();\n        let manager = BlobManager::new(pool);\n        let ctx = manager.connect();\n\n        let body_id = \"rs_test123.request\";\n        let chunk1 = BodyChunk::new(body_id, 0, b\"Hello, \".to_vec());\n        let chunk2 = BodyChunk::new(body_id, 1, b\"World!\".to_vec());\n\n        ctx.insert_chunk(&chunk1).unwrap();\n        ctx.insert_chunk(&chunk2).unwrap();\n\n        let chunks = ctx.get_chunks(body_id).unwrap();\n        assert_eq!(chunks.len(), 2);\n        assert_eq!(chunks[0].chunk_index, 0);\n        assert_eq!(chunks[0].data, b\"Hello, \");\n        assert_eq!(chunks[1].chunk_index, 1);\n        assert_eq!(chunks[1].data, b\"World!\");\n    }\n\n    #[test]\n    fn test_get_chunks_ordered_by_index() {\n        let pool = create_test_pool();\n        let manager = BlobManager::new(pool);\n        let ctx = manager.connect();\n\n        let body_id = \"rs_test123.request\";\n\n        // Insert out of order\n        ctx.insert_chunk(&BodyChunk::new(body_id, 2, b\"C\".to_vec())).unwrap();\n        ctx.insert_chunk(&BodyChunk::new(body_id, 0, b\"A\".to_vec())).unwrap();\n        ctx.insert_chunk(&BodyChunk::new(body_id, 1, b\"B\".to_vec())).unwrap();\n\n        let chunks = ctx.get_chunks(body_id).unwrap();\n        assert_eq!(chunks.len(), 3);\n        assert_eq!(chunks[0].data, b\"A\");\n        assert_eq!(chunks[1].data, b\"B\");\n        assert_eq!(chunks[2].data, b\"C\");\n    }\n\n    #[test]\n    fn test_delete_chunks() {\n        let pool = create_test_pool();\n        let manager = BlobManager::new(pool);\n        let ctx = manager.connect();\n\n        let body_id = \"rs_test123.request\";\n        ctx.insert_chunk(&BodyChunk::new(body_id, 0, b\"data\".to_vec())).unwrap();\n\n        assert!(ctx.body_exists(body_id).unwrap());\n\n        ctx.delete_chunks(body_id).unwrap();\n\n        assert!(!ctx.body_exists(body_id).unwrap());\n        assert_eq!(ctx.get_chunks(body_id).unwrap().len(), 0);\n    }\n\n    #[test]\n    fn test_delete_chunks_like() {\n        let pool = create_test_pool();\n        let manager = BlobManager::new(pool);\n        let ctx = manager.connect();\n\n        // Insert chunks for same response but different body types\n        ctx.insert_chunk(&BodyChunk::new(\"rs_abc.request\", 0, b\"req\".to_vec())).unwrap();\n        ctx.insert_chunk(&BodyChunk::new(\"rs_abc.response\", 0, b\"resp\".to_vec())).unwrap();\n        ctx.insert_chunk(&BodyChunk::new(\"rs_other.request\", 0, b\"other\".to_vec())).unwrap();\n\n        // Delete all bodies for rs_abc\n        ctx.delete_chunks_like(\"rs_abc.%\").unwrap();\n\n        // rs_abc bodies should be gone\n        assert!(!ctx.body_exists(\"rs_abc.request\").unwrap());\n        assert!(!ctx.body_exists(\"rs_abc.response\").unwrap());\n\n        // rs_other should still exist\n        assert!(ctx.body_exists(\"rs_other.request\").unwrap());\n    }\n\n    #[test]\n    fn test_get_body_size() {\n        let pool = create_test_pool();\n        let manager = BlobManager::new(pool);\n        let ctx = manager.connect();\n\n        let body_id = \"rs_test123.request\";\n        ctx.insert_chunk(&BodyChunk::new(body_id, 0, b\"Hello\".to_vec())).unwrap();\n        ctx.insert_chunk(&BodyChunk::new(body_id, 1, b\"World\".to_vec())).unwrap();\n\n        let size = ctx.get_body_size(body_id).unwrap();\n        assert_eq!(size, 10); // \"Hello\" + \"World\" = 10 bytes\n    }\n\n    #[test]\n    fn test_get_body_size_empty() {\n        let pool = create_test_pool();\n        let manager = BlobManager::new(pool);\n        let ctx = manager.connect();\n\n        let size = ctx.get_body_size(\"nonexistent\").unwrap();\n        assert_eq!(size, 0);\n    }\n\n    #[test]\n    fn test_body_exists() {\n        let pool = create_test_pool();\n        let manager = BlobManager::new(pool);\n        let ctx = manager.connect();\n\n        assert!(!ctx.body_exists(\"rs_test.request\").unwrap());\n\n        ctx.insert_chunk(&BodyChunk::new(\"rs_test.request\", 0, b\"data\".to_vec())).unwrap();\n\n        assert!(ctx.body_exists(\"rs_test.request\").unwrap());\n    }\n\n    #[test]\n    fn test_multiple_bodies_isolated() {\n        let pool = create_test_pool();\n        let manager = BlobManager::new(pool);\n        let ctx = manager.connect();\n\n        ctx.insert_chunk(&BodyChunk::new(\"body1\", 0, b\"data1\".to_vec())).unwrap();\n        ctx.insert_chunk(&BodyChunk::new(\"body2\", 0, b\"data2\".to_vec())).unwrap();\n\n        let chunks1 = ctx.get_chunks(\"body1\").unwrap();\n        let chunks2 = ctx.get_chunks(\"body2\").unwrap();\n\n        assert_eq!(chunks1.len(), 1);\n        assert_eq!(chunks1[0].data, b\"data1\");\n        assert_eq!(chunks2.len(), 1);\n        assert_eq!(chunks2[0].data, b\"data2\");\n    }\n\n    #[test]\n    fn test_large_chunk() {\n        let pool = create_test_pool();\n        let manager = BlobManager::new(pool);\n        let ctx = manager.connect();\n\n        // 1MB chunk\n        let large_data: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();\n        let body_id = \"rs_large.request\";\n\n        ctx.insert_chunk(&BodyChunk::new(body_id, 0, large_data.clone())).unwrap();\n\n        let chunks = ctx.get_chunks(body_id).unwrap();\n        assert_eq!(chunks.len(), 1);\n        assert_eq!(chunks[0].data, large_data);\n        assert_eq!(ctx.get_body_size(body_id).unwrap(), 1024 * 1024);\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/connection_or_tx.rs",
    "content": "use r2d2::PooledConnection;\nuse r2d2_sqlite::SqliteConnectionManager;\nuse rusqlite::{Connection, Statement, ToSql, Transaction};\n\npub enum ConnectionOrTx<'a> {\n    Connection(PooledConnection<SqliteConnectionManager>),\n    Transaction(&'a Transaction<'a>),\n}\n\nimpl<'a> ConnectionOrTx<'a> {\n    pub(crate) fn resolve(&self) -> &Connection {\n        match self {\n            ConnectionOrTx::Connection(c) => c,\n            ConnectionOrTx::Transaction(c) => c,\n        }\n    }\n\n    pub(crate) fn prepare(&self, sql: &str) -> rusqlite::Result<Statement<'_>> {\n        self.resolve().prepare(sql)\n    }\n\n    pub(crate) fn execute(&self, sql: &str, params: &[&dyn ToSql]) -> rusqlite::Result<usize> {\n        self.resolve().execute(sql, params)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/db_context.rs",
    "content": "use crate::connection_or_tx::ConnectionOrTx;\nuse crate::error::Error::ModelNotFound;\nuse crate::error::Result;\nuse crate::models::{AnyModel, UpsertModelInfo};\nuse crate::util::{ModelChangeEvent, ModelPayload, UpdateSource};\nuse rusqlite::{OptionalExtension, params};\nuse sea_query::{\n    Asterisk, Expr, Func, IntoColumnRef, IntoIden, IntoTableRef, OnConflict, Query, SimpleExpr,\n    SqliteQueryBuilder,\n};\nuse sea_query_rusqlite::RusqliteBinder;\nuse std::fmt::Debug;\nuse std::sync::mpsc;\n\npub struct DbContext<'a> {\n    pub(crate) _events_tx: mpsc::Sender<ModelPayload>,\n    pub(crate) conn: ConnectionOrTx<'a>,\n}\n\nimpl<'a> DbContext<'a> {\n    pub(crate) fn find_one<'s, M>(\n        &self,\n        col: impl IntoColumnRef + IntoIden + Clone,\n        value: impl Into<SimpleExpr> + Debug,\n    ) -> Result<M>\n    where\n        M: Into<AnyModel> + Clone + UpsertModelInfo,\n    {\n        let value_debug = format!(\"{:?}\", value);\n\n        let value_expr = value.into();\n        let (sql, params) = Query::select()\n            .from(M::table_name())\n            .column(Asterisk)\n            .cond_where(Expr::col(col.clone()).eq(value_expr))\n            .build_rusqlite(SqliteQueryBuilder);\n        let mut stmt = self.conn.prepare(sql.as_str()).expect(\"Failed to prepare query\");\n        match stmt.query_row(&*params.as_params(), M::from_row) {\n            Ok(result) => Ok(result),\n            Err(rusqlite::Error::QueryReturnedNoRows) => Err(ModelNotFound(format!(\n                r#\"table \"{}\" {} == {}\"#,\n                M::table_name().into_iden().to_string(),\n                col.into_iden().to_string(),\n                value_debug\n            ))),\n            Err(e) => Err(crate::error::Error::SqlError(e)),\n        }\n    }\n\n    pub(crate) fn find_optional<'s, M>(\n        &self,\n        col: impl IntoColumnRef,\n        value: impl Into<SimpleExpr>,\n    ) -> Option<M>\n    where\n        M: Into<AnyModel> + Clone + UpsertModelInfo,\n    {\n        let (sql, params) = Query::select()\n            .from(M::table_name())\n            .column(Asterisk)\n            .cond_where(Expr::col(col).eq(value))\n            .build_rusqlite(SqliteQueryBuilder);\n        let mut stmt = self.conn.prepare(sql.as_str()).expect(\"Failed to prepare query\");\n        stmt.query_row(&*params.as_params(), M::from_row)\n            .optional()\n            .expect(\"Failed to run find on DB\")\n    }\n\n    pub(crate) fn find_all<'s, M>(&self) -> Result<Vec<M>>\n    where\n        M: Into<AnyModel> + Clone + UpsertModelInfo,\n    {\n        let (order_by_col, order_by_dir) = M::order_by();\n        let (sql, params) = Query::select()\n            .from(M::table_name())\n            .column(Asterisk)\n            .order_by(order_by_col, order_by_dir)\n            .build_rusqlite(SqliteQueryBuilder);\n        let mut stmt = self.conn.resolve().prepare(sql.as_str())?;\n        let items = stmt.query_map(&*params.as_params(), M::from_row)?;\n        Ok(items.map(|v| v.unwrap()).collect())\n    }\n\n    pub(crate) fn find_many<'s, M>(\n        &self,\n        col: impl IntoColumnRef,\n        value: impl Into<SimpleExpr>,\n        limit: Option<u64>,\n    ) -> Result<Vec<M>>\n    where\n        M: Into<AnyModel> + Clone + UpsertModelInfo,\n    {\n        // TODO: Figure out how to do this conditional builder better\n        let (order_by_col, order_by_dir) = M::order_by();\n        let (sql, params) = if let Some(limit) = limit {\n            Query::select()\n                .from(M::table_name())\n                .column(Asterisk)\n                .cond_where(Expr::col(col).eq(value))\n                .limit(limit)\n                .order_by(order_by_col, order_by_dir)\n                .build_rusqlite(SqliteQueryBuilder)\n        } else {\n            Query::select()\n                .from(M::table_name())\n                .column(Asterisk)\n                .cond_where(Expr::col(col).eq(value))\n                .order_by(order_by_col, order_by_dir)\n                .build_rusqlite(SqliteQueryBuilder)\n        };\n\n        let mut stmt = self.conn.resolve().prepare(sql.as_str())?;\n        let items = stmt.query_map(&*params.as_params(), M::from_row)?;\n        Ok(items.map(|v| v.unwrap()).collect())\n    }\n\n    pub(crate) fn upsert<M>(&self, model: &M, source: &UpdateSource) -> Result<M>\n    where\n        M: Into<AnyModel> + From<AnyModel> + UpsertModelInfo + Clone,\n    {\n        self.upsert_one(\n            M::table_name(),\n            M::id_column(),\n            model.get_id().as_str(),\n            model.clone().insert_values(source)?,\n            M::update_columns(),\n            source,\n        )\n    }\n\n    fn upsert_one<M>(\n        &self,\n        table: impl IntoTableRef,\n        id_col: impl IntoIden + Eq + Clone,\n        id_val: &str,\n        other_values: Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>,\n        update_columns: Vec<impl IntoIden>,\n        source: &UpdateSource,\n    ) -> Result<M>\n    where\n        M: Into<AnyModel> + From<AnyModel> + UpsertModelInfo + Clone,\n    {\n        let id_iden = id_col.into_iden();\n        let mut column_vec = vec![id_iden.clone()];\n        let mut value_vec =\n            vec![if id_val == \"\" { M::generate_id().into() } else { id_val.into() }];\n\n        for (col, val) in other_values {\n            value_vec.push(val.into());\n            column_vec.push(col.into_iden());\n        }\n\n        let on_conflict = OnConflict::column(id_iden).update_columns(update_columns).to_owned();\n\n        let (sql, params) = Query::insert()\n            .into_table(table)\n            .columns(column_vec)\n            .values_panic(value_vec)\n            .on_conflict(on_conflict)\n            .returning(Query::returning().exprs(vec![\n                Expr::col(Asterisk),\n                Expr::expr(Func::cust(\"last_insert_rowid\")),\n                Expr::col(\"rowid\"),\n            ]))\n            .build_rusqlite(SqliteQueryBuilder);\n\n        let mut stmt = self.conn.resolve().prepare(sql.as_str())?;\n        let (m, created): (M, bool) = stmt.query_row(&*params.as_params(), |row| {\n            M::from_row(row).and_then(|m| {\n                let rowid: i64 = row.get(\"rowid\")?;\n                let last_rowid: i64 = row.get(\"last_insert_rowid()\")?;\n                Ok((m, rowid == last_rowid))\n            })\n        })?;\n\n        let payload = ModelPayload {\n            model: m.clone().into(),\n            update_source: source.clone(),\n            change: ModelChangeEvent::Upsert { created },\n        };\n\n        self.record_model_change(&payload)?;\n        let _ = self._events_tx.send(payload);\n\n        Ok(m)\n    }\n\n    pub(crate) fn delete<'s, M>(&self, m: &M, source: &UpdateSource) -> Result<M>\n    where\n        M: Into<AnyModel> + Clone + UpsertModelInfo,\n    {\n        let (sql, params) = Query::delete()\n            .from_table(M::table_name())\n            .cond_where(Expr::col(M::id_column().into_iden()).eq(m.get_id()))\n            .build_rusqlite(SqliteQueryBuilder);\n        self.conn.execute(sql.as_str(), &*params.as_params())?;\n\n        let payload = ModelPayload {\n            model: m.clone().into(),\n            update_source: source.clone(),\n            change: ModelChangeEvent::Delete,\n        };\n\n        self.record_model_change(&payload)?;\n        let _ = self._events_tx.send(payload);\n\n        Ok(m.clone())\n    }\n\n    fn record_model_change(&self, payload: &ModelPayload) -> Result<()> {\n        let payload_json = serde_json::to_string(payload)?;\n        let source_json = serde_json::to_string(&payload.update_source)?;\n        let change_json = serde_json::to_string(&payload.change)?;\n\n        self.conn.resolve().execute(\n            r#\"\n                INSERT INTO model_changes (model, model_id, change, update_source, payload)\n                VALUES (?1, ?2, ?3, ?4, ?5)\n            \"#,\n            params![\n                payload.model.model(),\n                payload.model.id(),\n                change_json,\n                source_json,\n                payload_json,\n            ],\n        )?;\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/error.rs",
    "content": "use serde::{Serialize, Serializer};\nuse thiserror::Error;\n\n#[derive(Error, Debug)]\npub enum Error {\n    #[error(\"SQL error: {0}\")]\n    SqlError(#[from] rusqlite::Error),\n\n    #[error(\"SQL Pool error: {0}\")]\n    SqlPoolError(#[from] r2d2::Error),\n\n    #[error(\"Database error: {0}\")]\n    Database(String),\n\n    #[error(\"IO error: {0}\")]\n    Io(#[from] std::io::Error),\n\n    #[error(\"JSON error: {0}\")]\n    JsonError(#[from] serde_json::Error),\n\n    #[error(\"Model not found: {0}\")]\n    ModelNotFound(String),\n\n    #[error(\"Model serialization error: {0}\")]\n    ModelSerializationError(String),\n\n    #[error(\"HTTP error: {0}\")]\n    GenericError(String),\n\n    #[error(\"DB Migration Failed: {0}\")]\n    MigrationError(String),\n\n    #[error(\"No base environment for {0}\")]\n    MissingBaseEnvironment(String),\n\n    #[error(\"Multiple base environments for {0}. Delete duplicates before continuing.\")]\n    MultipleBaseEnvironments(String),\n\n    #[error(\"unknown error\")]\n    Unknown,\n}\n\nimpl Serialize for Error {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        serializer.serialize_str(self.to_string().as_ref())\n    }\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates/yaak-models/src/lib.rs",
    "content": "use crate::blob_manager::{BlobManager, migrate_blob_db};\nuse crate::error::{Error, Result};\nuse crate::migrate::migrate_db;\nuse crate::query_manager::QueryManager;\nuse crate::util::ModelPayload;\nuse log::info;\nuse r2d2::Pool;\nuse r2d2_sqlite::SqliteConnectionManager;\nuse std::fs::create_dir_all;\nuse std::path::Path;\nuse std::sync::mpsc;\nuse std::time::Duration;\n\npub mod blob_manager;\nmod connection_or_tx;\npub mod db_context;\npub mod error;\npub mod migrate;\npub mod models;\npub mod queries;\npub mod query_manager;\npub mod render;\npub mod util;\n\n/// Initialize the database managers for standalone (non-Tauri) usage.\n///\n/// Returns a tuple of (QueryManager, BlobManager, event_receiver).\n/// The event_receiver can be used to listen for model change events.\npub fn init_standalone(\n    db_path: impl AsRef<Path>,\n    blob_path: impl AsRef<Path>,\n) -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {\n    let db_path = db_path.as_ref();\n    let blob_path = blob_path.as_ref();\n\n    // Create parent directories if needed\n    if let Some(parent) = db_path.parent() {\n        create_dir_all(parent)?;\n    }\n    if let Some(parent) = blob_path.parent() {\n        create_dir_all(parent)?;\n    }\n\n    // Main database pool\n    info!(\"Initializing app database {db_path:?}\");\n    let manager = SqliteConnectionManager::file(db_path);\n    let pool = Pool::builder()\n        .max_size(100)\n        .connection_timeout(Duration::from_secs(10))\n        .build(manager)\n        .map_err(|e| Error::Database(e.to_string()))?;\n\n    migrate_db(&pool)?;\n\n    info!(\"Initializing blobs database {blob_path:?}\");\n\n    // Blob database pool\n    let blob_manager = SqliteConnectionManager::file(blob_path);\n    let blob_pool = Pool::builder()\n        .max_size(50)\n        .connection_timeout(Duration::from_secs(10))\n        .build(blob_manager)\n        .map_err(|e| Error::Database(e.to_string()))?;\n\n    migrate_blob_db(&blob_pool)?;\n\n    let (tx, rx) = mpsc::channel();\n    let query_manager = QueryManager::new(pool, tx);\n    let blob_manager = BlobManager::new(blob_pool);\n\n    Ok((query_manager, blob_manager, rx))\n}\n\n/// Initialize the database managers with in-memory SQLite databases.\n/// Useful for testing and CI environments.\npub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<ModelPayload>)> {\n    // Main database pool\n    let manager = SqliteConnectionManager::memory();\n    let pool = Pool::builder()\n        .max_size(1) // In-memory DB doesn't support multiple connections\n        .build(manager)\n        .map_err(|e| Error::Database(e.to_string()))?;\n\n    migrate_db(&pool)?;\n\n    // Blob database pool\n    let blob_manager = SqliteConnectionManager::memory();\n    let blob_pool = Pool::builder()\n        .max_size(1)\n        .build(blob_manager)\n        .map_err(|e| Error::Database(e.to_string()))?;\n\n    migrate_blob_db(&blob_pool)?;\n\n    let (tx, rx) = mpsc::channel();\n    let query_manager = QueryManager::new(pool, tx);\n    let blob_manager = BlobManager::new(blob_pool);\n\n    Ok((query_manager, blob_manager, rx))\n}\n"
  },
  {
    "path": "crates/yaak-models/src/migrate.rs",
    "content": "use crate::error::Error::MigrationError;\nuse crate::error::Result;\nuse include_dir::{Dir, DirEntry, include_dir};\nuse log::{debug, info};\nuse r2d2::Pool;\nuse r2d2_sqlite::SqliteConnectionManager;\nuse rusqlite::{OptionalExtension, TransactionBehavior, params};\nuse sha2::{Digest, Sha384};\n\nstatic MIGRATIONS_DIR: Dir = include_dir!(\"$CARGO_MANIFEST_DIR/migrations\");\n\npub fn migrate_db(pool: &Pool<SqliteConnectionManager>) -> Result<()> {\n    info!(\"Running database migrations\");\n\n    // Ensure the table exists\n    // NOTE: Yaak used to use sqlx for migrations, so we need to mirror that table structure. We\n    //  are writing checksum but not verifying because we want to be able to change migrations after\n    //  a release in case something breaks.\n    pool.get()?.execute(\n        \"CREATE TABLE IF NOT EXISTS _sqlx_migrations (\n            version        BIGINT PRIMARY KEY,\n            description    TEXT NOT NULL,\n            installed_on   TIMESTAMP default CURRENT_TIMESTAMP NOT NULL,\n            success        BOOLEAN                             NOT NULL,\n            checksum       BLOB                                NOT NULL,\n            execution_time BIGINT                              NOT NULL\n        )\",\n        [],\n    )?;\n\n    // Read and sort all .sql files\n    let mut entries = MIGRATIONS_DIR\n        .entries()\n        .into_iter()\n        .filter(|e| e.path().extension().map(|ext| ext == \"sql\").unwrap_or(false))\n        .collect::<Vec<_>>();\n\n    // Ensure they're in the correct order\n    entries.sort_by_key(|e| e.path());\n\n    // Run each migration in a transaction\n    let mut num_migrations = 0;\n    let mut ran_migrations = 0;\n    for entry in entries {\n        num_migrations += 1;\n        let mut conn = pool.get()?;\n        let mut tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;\n        match run_migration(entry, &mut tx) {\n            Ok(ran) => {\n                if ran {\n                    ran_migrations += 1;\n                }\n                tx.commit()?\n            }\n            Err(e) => {\n                let msg = format!(\n                    \"{} failed with {}\",\n                    entry.path().file_name().unwrap().to_str().unwrap(),\n                    e.to_string()\n                );\n                tx.rollback()?;\n                return Err(MigrationError(msg));\n            }\n        };\n    }\n\n    if ran_migrations == 0 {\n        info!(\"No migrations to run out of {}\", num_migrations);\n    } else {\n        info!(\"Ran {}/{} migrations\", ran_migrations, num_migrations);\n    }\n\n    Ok(())\n}\n\nfn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> Result<bool> {\n    let start = std::time::Instant::now();\n    let (version, description) = split_migration_filename(migration_path.path().to_str().unwrap())\n        .expect(\"Failed to parse migration filename\");\n\n    // Skip if already applied\n    let row: Option<i64> = tx\n        .query_row(\"SELECT 1 FROM _sqlx_migrations WHERE version = ?\", [version.clone()], |r| {\n            r.get(0)\n        })\n        .optional()?;\n\n    if row.is_some() {\n        debug!(\"Skipping already run migration {description}\");\n        return Ok(false); // Migration was already run\n    }\n\n    let sql =\n        migration_path.as_file().unwrap().contents_utf8().expect(\"Failed to read migration file\");\n    info!(\"Applying migration {description}\");\n\n    // Split on `;`? → optional depending on how your SQL is structured\n    tx.execute_batch(&sql)?;\n\n    let execution_time = start.elapsed().as_nanos() as i64;\n    let checksum = sha384_hex_prefixed(sql.as_bytes());\n\n    // NOTE: The success column is never used. It's just there for sqlx compatibility.\n    tx.execute(\n        \"INSERT INTO _sqlx_migrations (version, description, execution_time, checksum, success) VALUES (?, ?, ?, ?, ?)\",\n        params![version, description, execution_time, checksum, true],\n    )?;\n\n    Ok(true)\n}\n\nfn split_migration_filename(filename: &str) -> Option<(String, String)> {\n    // Remove the .sql extension\n    let trimmed = filename.strip_suffix(\".sql\")?;\n\n    // Split on the first underscore\n    let mut parts = trimmed.splitn(2, '_');\n    let version = parts.next()?.to_string();\n    let description = parts.next()?.to_string();\n\n    Some((version, description))\n}\n\nfn sha384_hex_prefixed(input: &[u8]) -> String {\n    let mut hasher = Sha384::new();\n    hasher.update(input);\n    let result = hasher.finalize();\n\n    // Format as 0x... with uppercase hex\n    format!(\"0x{}\", hex::encode_upper(result))\n}\n"
  },
  {
    "path": "crates/yaak-models/src/models.rs",
    "content": "use crate::error::Result;\nuse crate::models::HttpRequestIden::{\n    Authentication, AuthenticationType, Body, BodyType, CreatedAt, Description, FolderId, Headers,\n    Method, Name, SortPriority, UpdatedAt, Url, UrlParameters, WorkspaceId,\n};\nuse crate::util::{UpdateSource, generate_prefixed_id};\nuse chrono::{NaiveDateTime, Utc};\nuse rusqlite::Row;\nuse schemars::JsonSchema;\nuse sea_query::Order::Desc;\nuse sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_def};\nuse serde::{Deserialize, Deserializer, Serialize};\nuse serde_json::Value;\nuse std::collections::BTreeMap;\nuse std::collections::HashMap;\nuse std::fmt::{Debug, Display};\nuse std::str::FromStr;\nuse ts_rs::TS;\n\n#[macro_export]\nmacro_rules! impl_model {\n    ($t:ty, $variant:ident) => {\n        impl $crate::Model for $t {\n            fn into_any(self) -> $crate::AnyModel {\n                $crate::AnyModel::$variant(self)\n            }\n        }\n    };\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\", tag = \"type\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum ProxySetting {\n    Enabled {\n        http: String,\n        https: String,\n        auth: Option<ProxySettingAuth>,\n\n        // These were added later, so give them defaults\n        #[serde(default)]\n        bypass: String,\n        #[serde(default)]\n        disabled: bool,\n    },\n    Disabled,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub struct ProxySettingAuth {\n    pub user: String,\n    pub password: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub struct ClientCertificate {\n    pub host: String,\n    #[serde(default)]\n    pub port: Option<i32>,\n    #[serde(default)]\n    pub crt_file: Option<String>,\n    #[serde(default)]\n    pub key_file: Option<String>,\n    #[serde(default)]\n    pub pfx_file: Option<String>,\n    #[serde(default)]\n    pub passphrase: Option<String>,\n    #[serde(default = \"default_true\")]\n    #[ts(optional, as = \"Option<bool>\")]\n    pub enabled: bool,\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub struct DnsOverride {\n    pub hostname: String,\n    #[serde(default)]\n    pub ipv4: Vec<String>,\n    #[serde(default)]\n    pub ipv6: Vec<String>,\n    #[serde(default = \"default_true\")]\n    #[ts(optional, as = \"Option<bool>\")]\n    pub enabled: bool,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum EditorKeymap {\n    Default,\n    Vim,\n    Vscode,\n    Emacs,\n}\n\nimpl FromStr for EditorKeymap {\n    type Err = crate::error::Error;\n\n    fn from_str(s: &str) -> Result<Self> {\n        match s {\n            \"default\" => Ok(Self::Default),\n            \"vscode\" => Ok(Self::Vscode),\n            \"vim\" => Ok(Self::Vim),\n            \"emacs\" => Ok(Self::Emacs),\n            _ => Ok(Self::default()),\n        }\n    }\n}\n\nimpl Display for EditorKeymap {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        let str = match self {\n            EditorKeymap::Default => \"default\".to_string(),\n            EditorKeymap::Vscode => \"vscode\".to_string(),\n            EditorKeymap::Vim => \"vim\".to_string(),\n            EditorKeymap::Emacs => \"emacs\".to_string(),\n        };\n        write!(f, \"{}\", str)\n    }\n}\n\nimpl Default for EditorKeymap {\n    fn default() -> Self {\n        Self::Default\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"settings\")]\npub struct Settings {\n    #[ts(type = \"\\\"settings\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n\n    pub appearance: String,\n    pub client_certificates: Vec<ClientCertificate>,\n    pub colored_methods: bool,\n    pub editor_font: Option<String>,\n    pub editor_font_size: i32,\n    pub editor_keymap: EditorKeymap,\n    pub editor_soft_wrap: bool,\n    pub hide_window_controls: bool,\n    // When true (primarily on Windows/Linux), use the native OS window title bar and controls\n    pub use_native_titlebar: bool,\n    pub interface_font: Option<String>,\n    pub interface_font_size: i32,\n    pub interface_scale: f32,\n    pub open_workspace_new_window: Option<bool>,\n    pub proxy: Option<ProxySetting>,\n    pub theme_dark: String,\n    pub theme_light: String,\n    pub update_channel: String,\n    pub hide_license_badge: bool,\n    pub autoupdate: bool,\n    pub auto_download_updates: bool,\n    pub check_notifications: bool,\n    pub hotkeys: HashMap<String, Vec<String>>,\n}\n\nimpl UpsertModelInfo for Settings {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        SettingsIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        SettingsIden::Id\n    }\n\n    fn generate_id() -> String {\n        panic!(\"Settings does not have unique IDs\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (SettingsIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use SettingsIden::*;\n        let proxy = match self.proxy {\n            None => None,\n            Some(p) => Some(serde_json::to_string(&p)?),\n        };\n        let client_certificates = serde_json::to_string(&self.client_certificates)?;\n        let hotkeys = serde_json::to_string(&self.hotkeys)?;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (Appearance, self.appearance.as_str().into()),\n            (ClientCertificates, client_certificates.into()),\n            (EditorFontSize, self.editor_font_size.into()),\n            (EditorKeymap, self.editor_keymap.to_string().into()),\n            (EditorSoftWrap, self.editor_soft_wrap.into()),\n            (EditorFont, self.editor_font.into()),\n            (InterfaceFont, self.interface_font.into()),\n            (InterfaceFontSize, self.interface_font_size.into()),\n            (InterfaceScale, self.interface_scale.into()),\n            (HideWindowControls, self.hide_window_controls.into()),\n            (UseNativeTitlebar, self.use_native_titlebar.into()),\n            (OpenWorkspaceNewWindow, self.open_workspace_new_window.into()),\n            (ThemeDark, self.theme_dark.as_str().into()),\n            (ThemeLight, self.theme_light.as_str().into()),\n            (UpdateChannel, self.update_channel.into()),\n            (HideLicenseBadge, self.hide_license_badge.into()),\n            (Autoupdate, self.autoupdate.into()),\n            (AutoDownloadUpdates, self.auto_download_updates.into()),\n            (ColoredMethods, self.colored_methods.into()),\n            (CheckNotifications, self.check_notifications.into()),\n            (Proxy, proxy.into()),\n            (Hotkeys, hotkeys.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            SettingsIden::UpdatedAt,\n            SettingsIden::Appearance,\n            SettingsIden::ClientCertificates,\n            SettingsIden::EditorFontSize,\n            SettingsIden::EditorKeymap,\n            SettingsIden::EditorSoftWrap,\n            SettingsIden::EditorFont,\n            SettingsIden::InterfaceFontSize,\n            SettingsIden::InterfaceScale,\n            SettingsIden::InterfaceFont,\n            SettingsIden::HideWindowControls,\n            SettingsIden::UseNativeTitlebar,\n            SettingsIden::OpenWorkspaceNewWindow,\n            SettingsIden::Proxy,\n            SettingsIden::ThemeDark,\n            SettingsIden::ThemeLight,\n            SettingsIden::UpdateChannel,\n            SettingsIden::HideLicenseBadge,\n            SettingsIden::Autoupdate,\n            SettingsIden::AutoDownloadUpdates,\n            SettingsIden::ColoredMethods,\n            SettingsIden::CheckNotifications,\n            SettingsIden::Hotkeys,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        let proxy: Option<String> = row.get(\"proxy\")?;\n        let client_certificates: String = row.get(\"client_certificates\")?;\n        let editor_keymap: String = row.get(\"editor_keymap\")?;\n        let hotkeys: String = row.get(\"hotkeys\")?;\n        Ok(Self {\n            id: row.get(\"id\")?,\n            model: row.get(\"model\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            appearance: row.get(\"appearance\")?,\n            client_certificates: serde_json::from_str(&client_certificates).unwrap_or_default(),\n            editor_font_size: row.get(\"editor_font_size\")?,\n            editor_font: row.get(\"editor_font\")?,\n            editor_keymap: EditorKeymap::from_str(editor_keymap.as_str()).unwrap(),\n            editor_soft_wrap: row.get(\"editor_soft_wrap\")?,\n            interface_font_size: row.get(\"interface_font_size\")?,\n            interface_scale: row.get(\"interface_scale\")?,\n            interface_font: row.get(\"interface_font\")?,\n            use_native_titlebar: row.get(\"use_native_titlebar\")?,\n            open_workspace_new_window: row.get(\"open_workspace_new_window\")?,\n            proxy: proxy.map(|p| -> ProxySetting { serde_json::from_str(p.as_str()).unwrap() }),\n            theme_dark: row.get(\"theme_dark\")?,\n            theme_light: row.get(\"theme_light\")?,\n            hide_window_controls: row.get(\"hide_window_controls\")?,\n            update_channel: row.get(\"update_channel\")?,\n            autoupdate: row.get(\"autoupdate\")?,\n            auto_download_updates: row.get(\"auto_download_updates\")?,\n            hide_license_badge: row.get(\"hide_license_badge\")?,\n            colored_methods: row.get(\"colored_methods\")?,\n            check_notifications: row.get(\"check_notifications\")?,\n            hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(),\n        })\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"workspaces\")]\npub struct Workspace {\n    #[ts(type = \"\\\"workspace\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n\n    #[ts(type = \"Record<string, any>\")]\n    pub authentication: BTreeMap<String, Value>,\n    pub authentication_type: Option<String>,\n    pub description: String,\n    pub headers: Vec<HttpRequestHeader>,\n    pub name: String,\n    pub encryption_key_challenge: Option<String>,\n\n    // Settings\n    #[serde(default = \"default_true\")]\n    pub setting_validate_certificates: bool,\n    #[serde(default = \"default_true\")]\n    pub setting_follow_redirects: bool,\n    pub setting_request_timeout: i32,\n    #[serde(default)]\n    pub setting_dns_overrides: Vec<DnsOverride>,\n}\n\nimpl UpsertModelInfo for Workspace {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        WorkspaceIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        WorkspaceIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"wk\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (WorkspaceIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use WorkspaceIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (Name, self.name.trim().into()),\n            (Authentication, serde_json::to_string(&self.authentication)?.into()),\n            (AuthenticationType, self.authentication_type.into()),\n            (Headers, serde_json::to_string(&self.headers)?.into()),\n            (Description, self.description.into()),\n            (EncryptionKeyChallenge, self.encryption_key_challenge.into()),\n            (SettingFollowRedirects, self.setting_follow_redirects.into()),\n            (SettingRequestTimeout, self.setting_request_timeout.into()),\n            (SettingValidateCertificates, self.setting_validate_certificates.into()),\n            (SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            WorkspaceIden::UpdatedAt,\n            WorkspaceIden::Name,\n            WorkspaceIden::Authentication,\n            WorkspaceIden::AuthenticationType,\n            WorkspaceIden::Headers,\n            WorkspaceIden::Description,\n            WorkspaceIden::EncryptionKeyChallenge,\n            WorkspaceIden::SettingRequestTimeout,\n            WorkspaceIden::SettingFollowRedirects,\n            WorkspaceIden::SettingRequestTimeout,\n            WorkspaceIden::SettingValidateCertificates,\n            WorkspaceIden::SettingDnsOverrides,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        let headers: String = row.get(\"headers\")?;\n        let authentication: String = row.get(\"authentication\")?;\n        let setting_dns_overrides: String = row.get(\"setting_dns_overrides\")?;\n        Ok(Self {\n            id: row.get(\"id\")?,\n            model: row.get(\"model\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            name: row.get(\"name\")?,\n            description: row.get(\"description\")?,\n            encryption_key_challenge: row.get(\"encryption_key_challenge\")?,\n            headers: serde_json::from_str(&headers).unwrap_or_default(),\n            authentication: serde_json::from_str(&authentication).unwrap_or_default(),\n            authentication_type: row.get(\"authentication_type\")?,\n            setting_follow_redirects: row.get(\"setting_follow_redirects\")?,\n            setting_request_timeout: row.get(\"setting_request_timeout\")?,\n            setting_validate_certificates: row.get(\"setting_validate_certificates\")?,\n            setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),\n        })\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub struct EncryptedKey {\n    pub encrypted_key: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"workspace_metas\")]\npub struct WorkspaceMeta {\n    #[ts(type = \"\\\"workspace_meta\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub workspace_id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub encryption_key: Option<EncryptedKey>,\n    pub setting_sync_dir: Option<String>,\n}\n\nimpl UpsertModelInfo for WorkspaceMeta {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        WorkspaceMetaIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        WorkspaceMetaIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"wm\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (WorkspaceMetaIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use WorkspaceMetaIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (WorkspaceId, self.workspace_id.into()),\n            (EncryptionKey, self.encryption_key.map(|e| serde_json::to_string(&e).unwrap()).into()),\n            (SettingSyncDir, self.setting_sync_dir.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            WorkspaceMetaIden::UpdatedAt,\n            WorkspaceMetaIden::EncryptionKey,\n            WorkspaceMetaIden::SettingSyncDir,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        let encryption_key: Option<String> = row.get(\"encryption_key\")?;\n        Ok(Self {\n            id: row.get(\"id\")?,\n            workspace_id: row.get(\"workspace_id\")?,\n            model: row.get(\"model\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            encryption_key: encryption_key.map(|e| serde_json::from_str(&e).unwrap()),\n            setting_sync_dir: row.get(\"setting_sync_dir\")?,\n        })\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum CookieDomain {\n    HostOnly(String),\n    Suffix(String),\n    NotPresent,\n    Empty,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum CookieExpires {\n    AtUtc(String),\n    SessionEnd,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[ts(export, export_to = \"gen_models.ts\")]\npub struct Cookie {\n    pub raw_cookie: String,\n    pub domain: CookieDomain,\n    pub expires: CookieExpires,\n    pub path: (String, bool),\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"cookie_jars\")]\npub struct CookieJar {\n    #[ts(type = \"\\\"cookie_jar\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub workspace_id: String,\n\n    pub cookies: Vec<Cookie>,\n    pub name: String,\n}\n\nimpl UpsertModelInfo for CookieJar {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        CookieJarIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        CookieJarIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"cj\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (CookieJarIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use CookieJarIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (WorkspaceId, self.workspace_id.into()),\n            (Name, self.name.trim().into()),\n            (Cookies, serde_json::to_string(&self.cookies)?.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            CookieJarIden::UpdatedAt,\n            CookieJarIden::Name,\n            CookieJarIden::Cookies,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        let cookies: String = row.get(\"cookies\")?;\n        Ok(Self {\n            id: row.get(\"id\")?,\n            model: row.get(\"model\")?,\n            workspace_id: row.get(\"workspace_id\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            name: row.get(\"name\")?,\n            cookies: serde_json::from_str(cookies.as_str()).unwrap_or_default(),\n        })\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"environments\")]\npub struct Environment {\n    #[ts(type = \"\\\"environment\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub workspace_id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n\n    pub name: String,\n    pub public: bool,\n    #[deprecated(\n        note = \"parent_model is used instead. This field will be removed when schema field is added for sync/export.\"\n    )]\n    #[ts(skip)]\n    pub base: bool,\n    pub parent_model: String,\n    pub parent_id: Option<String>,\n    /// Variables defined in this environment scope.\n    /// Child environments override parent variables by name.\n    pub variables: Vec<EnvironmentVariable>,\n    pub color: Option<String>,\n    pub sort_priority: f64,\n}\n\nimpl UpsertModelInfo for Environment {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        EnvironmentIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        EnvironmentIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"ev\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (EnvironmentIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use EnvironmentIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (WorkspaceId, self.workspace_id.into()),\n            (ParentId, self.parent_id.into()),\n            (ParentModel, self.parent_model.into()),\n            (Color, self.color.into()),\n            (Name, self.name.trim().into()),\n            (Public, self.public.into()),\n            (SortPriority, self.sort_priority.into()),\n            (Variables, serde_json::to_string(&self.variables)?.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            EnvironmentIden::UpdatedAt,\n            EnvironmentIden::ParentId,\n            EnvironmentIden::ParentModel,\n            EnvironmentIden::Color,\n            EnvironmentIden::Name,\n            EnvironmentIden::Public,\n            EnvironmentIden::Variables,\n            EnvironmentIden::SortPriority,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        let variables: String = row.get(\"variables\")?;\n        let parent_model = row.get(\"parent_model\")?;\n        let base = parent_model == \"workspace\";\n        Ok(Self {\n            id: row.get(\"id\")?,\n            model: row.get(\"model\")?,\n            workspace_id: row.get(\"workspace_id\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            parent_id: row.get(\"parent_id\")?,\n            parent_model,\n            color: row.get(\"color\")?,\n            name: row.get(\"name\")?,\n            public: row.get(\"public\")?,\n            variables: serde_json::from_str(variables.as_str()).unwrap_or_default(),\n            sort_priority: row.get(\"sort_priority\")?,\n\n            // Deprecated field, but we need to keep it around for a couple of versions\n            // for compatibility because sync/export don't have a schema field\n            #[allow(deprecated)]\n            base,\n        })\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub struct EnvironmentVariable {\n    #[serde(default = \"default_true\")]\n    #[ts(optional, as = \"Option<bool>\")]\n    pub enabled: bool,\n    pub name: String,\n    pub value: String,\n    #[ts(optional, as = \"Option<String>\")]\n    pub id: Option<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub struct ParentAuthentication {\n    #[ts(type = \"Record<string, any>\")]\n    pub authentication: BTreeMap<String, Value>,\n    pub authentication_type: Option<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub struct ParentHeaders {\n    pub headers: Vec<HttpRequestHeader>,\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"folders\")]\npub struct Folder {\n    #[ts(type = \"\\\"folder\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub workspace_id: String,\n    pub folder_id: Option<String>,\n\n    #[ts(type = \"Record<string, any>\")]\n    pub authentication: BTreeMap<String, Value>,\n    pub authentication_type: Option<String>,\n    pub description: String,\n    pub headers: Vec<HttpRequestHeader>,\n    pub name: String,\n    pub sort_priority: f64,\n}\n\nimpl UpsertModelInfo for Folder {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        FolderIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        FolderIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"fl\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (FolderIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use FolderIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (WorkspaceId, self.workspace_id.into()),\n            (FolderId, self.folder_id.into()),\n            (Authentication, serde_json::to_string(&self.authentication)?.into()),\n            (AuthenticationType, self.authentication_type.into()),\n            (Headers, serde_json::to_string(&self.headers)?.into()),\n            (Description, self.description.into()),\n            (Name, self.name.trim().into()),\n            (SortPriority, self.sort_priority.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            FolderIden::UpdatedAt,\n            FolderIden::Name,\n            FolderIden::Authentication,\n            FolderIden::AuthenticationType,\n            FolderIden::Headers,\n            FolderIden::Description,\n            FolderIden::FolderId,\n            FolderIden::SortPriority,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        let headers: String = row.get(\"headers\")?;\n        let authentication: String = row.get(\"authentication\")?;\n        Ok(Self {\n            id: row.get(\"id\")?,\n            model: row.get(\"model\")?,\n            sort_priority: row.get(\"sort_priority\")?,\n            workspace_id: row.get(\"workspace_id\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            folder_id: row.get(\"folder_id\")?,\n            name: row.get(\"name\")?,\n            description: row.get(\"description\")?,\n            headers: serde_json::from_str(&headers).unwrap_or_default(),\n            authentication_type: row.get(\"authentication_type\")?,\n            authentication: serde_json::from_str(&authentication).unwrap_or_default(),\n        })\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub struct HttpRequestHeader {\n    #[serde(default = \"default_true\")]\n    #[ts(optional, as = \"Option<bool>\")]\n    pub enabled: bool,\n    pub name: String,\n    pub value: String,\n    #[ts(optional, as = \"Option<String>\")]\n    pub id: Option<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub struct HttpUrlParameter {\n    #[serde(default = \"default_true\")]\n    #[ts(optional, as = \"Option<bool>\")]\n    pub enabled: bool,\n    /// Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`\n    /// Other entries are appended as query parameters\n    pub name: String,\n    pub value: String,\n    #[ts(optional, as = \"Option<String>\")]\n    pub id: Option<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"http_requests\")]\npub struct HttpRequest {\n    #[ts(type = \"\\\"http_request\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub workspace_id: String,\n    pub folder_id: Option<String>,\n\n    #[ts(type = \"Record<string, any>\")]\n    pub authentication: BTreeMap<String, Value>,\n    pub authentication_type: Option<String>,\n    #[ts(type = \"Record<string, any>\")]\n    pub body: BTreeMap<String, Value>,\n    pub body_type: Option<String>,\n    pub description: String,\n    pub headers: Vec<HttpRequestHeader>,\n    #[serde(default = \"default_http_method\")]\n    pub method: String,\n    pub name: String,\n    pub sort_priority: f64,\n    pub url: String,\n    /// URL parameters used for both path placeholders (`:id`) and query string entries.\n    pub url_parameters: Vec<HttpUrlParameter>,\n}\n\nimpl UpsertModelInfo for HttpRequest {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        HttpRequestIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        HttpRequestIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"rq\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (HttpResponseIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.to_string()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (WorkspaceId, self.workspace_id.into()),\n            (FolderId, self.folder_id.into()),\n            (Name, self.name.trim().into()),\n            (Description, self.description.into()),\n            (Url, self.url.into()),\n            (UrlParameters, serde_json::to_string(&self.url_parameters)?.into()),\n            (Method, self.method.into()),\n            (Body, serde_json::to_string(&self.body)?.into()),\n            (BodyType, self.body_type.into()),\n            (Authentication, serde_json::to_string(&self.authentication)?.into()),\n            (AuthenticationType, self.authentication_type.into()),\n            (Headers, serde_json::to_string(&self.headers)?.into()),\n            (SortPriority, self.sort_priority.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            UpdatedAt,\n            WorkspaceId,\n            Name,\n            Description,\n            FolderId,\n            Method,\n            Headers,\n            Body,\n            BodyType,\n            Authentication,\n            AuthenticationType,\n            Url,\n            UrlParameters,\n            SortPriority,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self> {\n        let url_parameters: String = row.get(\"url_parameters\")?;\n        let body: String = row.get(\"body\")?;\n        let authentication: String = row.get(\"authentication\")?;\n        let headers: String = row.get(\"headers\")?;\n        Ok(Self {\n            id: row.get(\"id\")?,\n            model: row.get(\"model\")?,\n            workspace_id: row.get(\"workspace_id\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            authentication: serde_json::from_str(authentication.as_str()).unwrap_or_default(),\n            authentication_type: row.get(\"authentication_type\")?,\n            body: serde_json::from_str(body.as_str()).unwrap_or_default(),\n            body_type: row.get(\"body_type\")?,\n            description: row.get(\"description\")?,\n            folder_id: row.get(\"folder_id\")?,\n            headers: serde_json::from_str(headers.as_str()).unwrap_or_default(),\n            method: row.get(\"method\")?,\n            name: row.get(\"name\")?,\n            sort_priority: row.get(\"sort_priority\")?,\n            url: row.get(\"url\")?,\n            url_parameters: serde_json::from_str(url_parameters.as_str()).unwrap_or_default(),\n        })\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum WebsocketConnectionState {\n    Initialized,\n    Connected,\n    Closing,\n    Closed,\n}\n\nimpl Default for WebsocketConnectionState {\n    fn default() -> Self {\n        Self::Initialized\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"websocket_connections\")]\npub struct WebsocketConnection {\n    #[ts(type = \"\\\"websocket_connection\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub workspace_id: String,\n    pub request_id: String,\n\n    pub elapsed: i32,\n    pub error: Option<String>,\n    pub headers: Vec<HttpResponseHeader>,\n    pub state: WebsocketConnectionState,\n    pub status: i32,\n    pub url: String,\n}\n\nimpl UpsertModelInfo for WebsocketConnection {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        WebsocketConnectionIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        WebsocketConnectionIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"wc\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (WebsocketConnectionIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use WebsocketConnectionIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (WorkspaceId, self.workspace_id.into()),\n            (RequestId, self.request_id.into()),\n            (Elapsed, self.elapsed.into()),\n            (Error, self.error.into()),\n            (Headers, serde_json::to_string(&self.headers)?.into()),\n            (State, serde_json::to_value(&self.state)?.as_str().into()),\n            (Status, self.status.into()),\n            (Url, self.url.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            WebsocketConnectionIden::UpdatedAt,\n            WebsocketConnectionIden::Elapsed,\n            WebsocketConnectionIden::Error,\n            WebsocketConnectionIden::Headers,\n            WebsocketConnectionIden::State,\n            WebsocketConnectionIden::Status,\n            WebsocketConnectionIden::Url,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        let headers: String = row.get(\"headers\")?;\n        let state: String = row.get(\"state\")?;\n        Ok(Self {\n            id: row.get(\"id\")?,\n            model: row.get(\"model\")?,\n            workspace_id: row.get(\"workspace_id\")?,\n            request_id: row.get(\"request_id\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            url: row.get(\"url\")?,\n            headers: serde_json::from_str(headers.as_str()).unwrap_or_default(),\n            elapsed: row.get(\"elapsed\")?,\n            error: row.get(\"error\")?,\n            state: serde_json::from_str(format!(r#\"\"{state}\"\"#).as_str()).unwrap(),\n            status: row.get(\"status\")?,\n        })\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum WebsocketMessageType {\n    Text,\n    Binary,\n}\n\nimpl Default for WebsocketMessageType {\n    fn default() -> Self {\n        Self::Text\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"websocket_requests\")]\npub struct WebsocketRequest {\n    #[ts(type = \"\\\"websocket_request\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub workspace_id: String,\n    pub folder_id: Option<String>,\n\n    #[ts(type = \"Record<string, any>\")]\n    pub authentication: BTreeMap<String, Value>,\n    pub authentication_type: Option<String>,\n    pub description: String,\n    pub headers: Vec<HttpRequestHeader>,\n    pub message: String,\n    pub name: String,\n    pub sort_priority: f64,\n    pub url: String,\n    /// URL parameters used for both path placeholders (`:id`) and query string entries.\n    pub url_parameters: Vec<HttpUrlParameter>,\n}\n\nimpl UpsertModelInfo for WebsocketRequest {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        WebsocketRequestIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        WebsocketRequestIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"wr\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (WebsocketRequestIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use WebsocketRequestIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (WorkspaceId, self.workspace_id.into()),\n            (FolderId, self.folder_id.as_ref().map(|s| s.as_str()).into()),\n            (Authentication, serde_json::to_string(&self.authentication)?.into()),\n            (AuthenticationType, self.authentication_type.into()),\n            (Description, self.description.into()),\n            (Headers, serde_json::to_string(&self.headers)?.into()),\n            (Message, self.message.into()),\n            (Name, self.name.trim().into()),\n            (SortPriority, self.sort_priority.into()),\n            (Url, self.url.into()),\n            (UrlParameters, serde_json::to_string(&self.url_parameters)?.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            WebsocketRequestIden::UpdatedAt,\n            WebsocketRequestIden::WorkspaceId,\n            WebsocketRequestIden::FolderId,\n            WebsocketRequestIden::Authentication,\n            WebsocketRequestIden::AuthenticationType,\n            WebsocketRequestIden::Description,\n            WebsocketRequestIden::Headers,\n            WebsocketRequestIden::Message,\n            WebsocketRequestIden::Name,\n            WebsocketRequestIden::SortPriority,\n            WebsocketRequestIden::Url,\n            WebsocketRequestIden::UrlParameters,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        let url_parameters: String = row.get(\"url_parameters\")?;\n        let authentication: String = row.get(\"authentication\")?;\n        let headers: String = row.get(\"headers\")?;\n        Ok(Self {\n            id: row.get(\"id\")?,\n            model: row.get(\"model\")?,\n            sort_priority: row.get(\"sort_priority\")?,\n            workspace_id: row.get(\"workspace_id\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            url: row.get(\"url\")?,\n            url_parameters: serde_json::from_str(url_parameters.as_str()).unwrap_or_default(),\n            message: row.get(\"message\")?,\n            description: row.get(\"description\")?,\n            authentication: serde_json::from_str(authentication.as_str()).unwrap_or_default(),\n            authentication_type: row.get(\"authentication_type\")?,\n            headers: serde_json::from_str(headers.as_str()).unwrap_or_default(),\n            folder_id: row.get(\"folder_id\")?,\n            name: row.get(\"name\")?,\n        })\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum WebsocketEventType {\n    Binary,\n    Close,\n    Frame,\n    Open,\n    Ping,\n    Pong,\n    Text,\n}\n\nimpl Default for WebsocketEventType {\n    fn default() -> Self {\n        Self::Text\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"websocket_events\")]\npub struct WebsocketEvent {\n    #[ts(type = \"\\\"websocket_event\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub workspace_id: String,\n    pub request_id: String,\n    pub connection_id: String,\n    pub is_server: bool,\n\n    pub message: Vec<u8>,\n    pub message_type: WebsocketEventType,\n}\n\nimpl UpsertModelInfo for WebsocketEvent {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        WebsocketEventIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        WebsocketEventIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"we\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (WebsocketEventIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use WebsocketEventIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (WorkspaceId, self.workspace_id.into()),\n            (ConnectionId, self.connection_id.into()),\n            (RequestId, self.request_id.into()),\n            (MessageType, serde_json::to_string(&self.message_type)?.into()),\n            (IsServer, self.is_server.into()),\n            (Message, self.message.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            WebsocketEventIden::UpdatedAt,\n            WebsocketEventIden::MessageType,\n            WebsocketEventIden::IsServer,\n            WebsocketEventIden::Message,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        let message_type: String = row.get(\"message_type\")?;\n        Ok(Self {\n            id: row.get(\"id\")?,\n            model: row.get(\"model\")?,\n            workspace_id: row.get(\"workspace_id\")?,\n            request_id: row.get(\"request_id\")?,\n            connection_id: row.get(\"connection_id\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            message: row.get(\"message\")?,\n            is_server: row.get(\"is_server\")?,\n            message_type: serde_json::from_str(message_type.as_str()).unwrap_or_default(),\n        })\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub struct HttpResponseHeader {\n    pub name: String,\n    pub value: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum HttpResponseState {\n    Initialized,\n    Connected,\n    Closed,\n}\n\nimpl Default for HttpResponseState {\n    fn default() -> Self {\n        Self::Initialized\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"http_responses\")]\npub struct HttpResponse {\n    #[ts(type = \"\\\"http_response\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub workspace_id: String,\n    pub request_id: String,\n\n    pub body_path: Option<String>,\n    pub content_length: Option<i32>,\n    pub content_length_compressed: Option<i32>,\n    pub elapsed: i32,\n    pub elapsed_headers: i32,\n    pub elapsed_dns: i32,\n    pub error: Option<String>,\n    pub headers: Vec<HttpResponseHeader>,\n    pub remote_addr: Option<String>,\n    pub request_content_length: Option<i32>,\n    pub request_headers: Vec<HttpResponseHeader>,\n    pub status: i32,\n    pub status_reason: Option<String>,\n    pub state: HttpResponseState,\n    pub url: String,\n    pub version: Option<String>,\n}\n\nimpl UpsertModelInfo for HttpResponse {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        HttpResponseIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        HttpResponseIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"rs\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (HttpResponseIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use HttpResponseIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (RequestId, self.request_id.into()),\n            (WorkspaceId, self.workspace_id.into()),\n            (BodyPath, self.body_path.into()),\n            (ContentLength, self.content_length.into()),\n            (ContentLengthCompressed, self.content_length_compressed.into()),\n            (Elapsed, self.elapsed.into()),\n            (ElapsedHeaders, self.elapsed_headers.into()),\n            (ElapsedDns, self.elapsed_dns.into()),\n            (Error, self.error.into()),\n            (Headers, serde_json::to_string(&self.headers)?.into()),\n            (RemoteAddr, self.remote_addr.into()),\n            (RequestHeaders, serde_json::to_string(&self.request_headers)?.into()),\n            (State, serde_json::to_value(self.state)?.as_str().into()),\n            (Status, self.status.into()),\n            (StatusReason, self.status_reason.into()),\n            (Url, self.url.into()),\n            (Version, self.version.into()),\n            (RequestContentLength, self.request_content_length.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            HttpResponseIden::UpdatedAt,\n            HttpResponseIden::BodyPath,\n            HttpResponseIden::ContentLength,\n            HttpResponseIden::ContentLengthCompressed,\n            HttpResponseIden::Elapsed,\n            HttpResponseIden::ElapsedHeaders,\n            HttpResponseIden::ElapsedDns,\n            HttpResponseIden::Error,\n            HttpResponseIden::Headers,\n            HttpResponseIden::RemoteAddr,\n            HttpResponseIden::RequestContentLength,\n            HttpResponseIden::RequestHeaders,\n            HttpResponseIden::State,\n            HttpResponseIden::Status,\n            HttpResponseIden::StatusReason,\n            HttpResponseIden::Url,\n            HttpResponseIden::Version,\n        ]\n    }\n\n    fn from_row(r: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        let headers: String = r.get(\"headers\")?;\n        let state: String = r.get(\"state\")?;\n        Ok(Self {\n            id: r.get(\"id\")?,\n            model: r.get(\"model\")?,\n            workspace_id: r.get(\"workspace_id\")?,\n            request_id: r.get(\"request_id\")?,\n            created_at: r.get(\"created_at\")?,\n            updated_at: r.get(\"updated_at\")?,\n            error: r.get(\"error\")?,\n            url: r.get(\"url\")?,\n            content_length: r.get(\"content_length\")?,\n            content_length_compressed: r.get(\"content_length_compressed\").unwrap_or_default(),\n            version: r.get(\"version\")?,\n            elapsed: r.get(\"elapsed\")?,\n            elapsed_headers: r.get(\"elapsed_headers\")?,\n            elapsed_dns: r.get(\"elapsed_dns\").unwrap_or_default(),\n            remote_addr: r.get(\"remote_addr\")?,\n            status: r.get(\"status\")?,\n            status_reason: r.get(\"status_reason\")?,\n            state: serde_json::from_str(format!(r#\"\"{state}\"\"#).as_str()).unwrap(),\n            body_path: r.get(\"body_path\")?,\n            headers: serde_json::from_str(headers.as_str()).unwrap_or_default(),\n            request_content_length: r.get(\"request_content_length\").unwrap_or_default(),\n            request_headers: serde_json::from_str(\n                r.get::<_, String>(\"request_headers\").unwrap_or_default().as_str(),\n            )\n            .unwrap_or_default(),\n        })\n    }\n}\n\n/// Serializable representation of HTTP response events for DB storage.\n/// This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.\n/// The `From` impl is in yaak-http to avoid circular dependencies.\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum HttpResponseEventData {\n    Setting {\n        name: String,\n        value: String,\n    },\n    Info {\n        message: String,\n    },\n    Redirect {\n        url: String,\n        status: u16,\n        behavior: String,\n        #[serde(default)]\n        dropped_body: bool,\n        #[serde(default)]\n        dropped_headers: Vec<String>,\n    },\n    SendUrl {\n        method: String,\n        #[serde(default)]\n        scheme: String,\n        #[serde(default)]\n        username: String,\n        #[serde(default)]\n        password: String,\n        #[serde(default)]\n        host: String,\n        #[serde(default)]\n        port: u16,\n        path: String,\n        #[serde(default)]\n        query: String,\n        #[serde(default)]\n        fragment: String,\n    },\n    ReceiveUrl {\n        version: String,\n        status: String,\n    },\n    HeaderUp {\n        name: String,\n        value: String,\n    },\n    HeaderDown {\n        name: String,\n        value: String,\n    },\n    ChunkSent {\n        bytes: usize,\n    },\n    ChunkReceived {\n        bytes: usize,\n    },\n    DnsResolved {\n        hostname: String,\n        addresses: Vec<String>,\n        duration: u64,\n        overridden: bool,\n    },\n}\n\nimpl Default for HttpResponseEventData {\n    fn default() -> Self {\n        Self::Info { message: String::new() }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"http_response_events\")]\npub struct HttpResponseEvent {\n    #[ts(type = \"\\\"http_response_event\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub workspace_id: String,\n    pub response_id: String,\n    pub event: HttpResponseEventData,\n}\n\nimpl UpsertModelInfo for HttpResponseEvent {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        HttpResponseEventIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        HttpResponseEventIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"re\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (HttpResponseEventIden::CreatedAt, Order::Asc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use HttpResponseEventIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (WorkspaceId, self.workspace_id.into()),\n            (ResponseId, self.response_id.into()),\n            (Event, serde_json::to_string(&self.event)?.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            HttpResponseEventIden::UpdatedAt,\n            HttpResponseEventIden::Event,\n        ]\n    }\n\n    fn from_row(r: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        let event: String = r.get(\"event\")?;\n        Ok(Self {\n            id: r.get(\"id\")?,\n            model: r.get(\"model\")?,\n            workspace_id: r.get(\"workspace_id\")?,\n            response_id: r.get(\"response_id\")?,\n            created_at: r.get(\"created_at\")?,\n            updated_at: r.get(\"updated_at\")?,\n            event: serde_json::from_str(&event).unwrap_or_default(),\n        })\n    }\n}\n\nimpl HttpResponseEvent {\n    pub fn new(response_id: &str, workspace_id: &str, event: HttpResponseEventData) -> Self {\n        Self {\n            model: \"http_response_event\".to_string(),\n            id: Self::generate_id(),\n            created_at: Utc::now().naive_utc(),\n            updated_at: Utc::now().naive_utc(),\n            workspace_id: workspace_id.to_string(),\n            response_id: response_id.to_string(),\n            event,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"graphql_introspections\")]\npub struct GraphQlIntrospection {\n    #[ts(type = \"\\\"graphql_introspection\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub workspace_id: String,\n    pub request_id: String,\n    pub content: Option<String>,\n}\n\nimpl UpsertModelInfo for GraphQlIntrospection {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        GraphQlIntrospectionIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        GraphQlIntrospectionIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"gi\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (GraphQlIntrospectionIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use GraphQlIntrospectionIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (WorkspaceId, self.workspace_id.into()),\n            (RequestId, self.request_id.into()),\n            (Content, self.content.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            GraphQlIntrospectionIden::UpdatedAt,\n            GraphQlIntrospectionIden::Content,\n        ]\n    }\n\n    fn from_row(r: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        Ok(Self {\n            id: r.get(\"id\")?,\n            model: r.get(\"model\")?,\n            created_at: r.get(\"created_at\")?,\n            updated_at: r.get(\"updated_at\")?,\n            workspace_id: r.get(\"workspace_id\")?,\n            request_id: r.get(\"request_id\")?,\n            content: r.get(\"content\")?,\n        })\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"grpc_requests\")]\npub struct GrpcRequest {\n    #[ts(type = \"\\\"grpc_request\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub workspace_id: String,\n    pub folder_id: Option<String>,\n\n    pub authentication_type: Option<String>,\n    #[ts(type = \"Record<string, any>\")]\n    pub authentication: BTreeMap<String, Value>,\n    pub description: String,\n    pub message: String,\n    pub metadata: Vec<HttpRequestHeader>,\n    pub method: Option<String>,\n    pub name: String,\n    pub service: Option<String>,\n    pub sort_priority: f64,\n    /// Server URL (http for plaintext or https for secure)\n    pub url: String,\n}\n\nimpl UpsertModelInfo for GrpcRequest {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        GrpcRequestIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        GrpcRequestIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"gr\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (GrpcRequestIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use GrpcRequestIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (Name, self.name.trim().into()),\n            (Description, self.description.into()),\n            (WorkspaceId, self.workspace_id.into()),\n            (FolderId, self.folder_id.into()),\n            (SortPriority, self.sort_priority.into()),\n            (Url, self.url.into()),\n            (Service, self.service.into()),\n            (Method, self.method.into()),\n            (Message, self.message.into()),\n            (AuthenticationType, self.authentication_type.into()),\n            (Authentication, serde_json::to_string(&self.authentication)?.into()),\n            (Metadata, serde_json::to_string(&self.metadata)?.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            GrpcRequestIden::UpdatedAt,\n            GrpcRequestIden::WorkspaceId,\n            GrpcRequestIden::Name,\n            GrpcRequestIden::Description,\n            GrpcRequestIden::FolderId,\n            GrpcRequestIden::SortPriority,\n            GrpcRequestIden::Url,\n            GrpcRequestIden::Service,\n            GrpcRequestIden::Method,\n            GrpcRequestIden::Message,\n            GrpcRequestIden::AuthenticationType,\n            GrpcRequestIden::Authentication,\n            GrpcRequestIden::Metadata,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        let authentication: String = row.get(\"authentication\")?;\n        let metadata: String = row.get(\"metadata\")?;\n        Ok(Self {\n            id: row.get(\"id\")?,\n            model: row.get(\"model\")?,\n            workspace_id: row.get(\"workspace_id\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            folder_id: row.get(\"folder_id\")?,\n            name: row.get(\"name\")?,\n            description: row.get(\"description\")?,\n            service: row.get(\"service\")?,\n            method: row.get(\"method\")?,\n            message: row.get(\"message\")?,\n            authentication_type: row.get(\"authentication_type\")?,\n            authentication: serde_json::from_str(authentication.as_str()).unwrap_or_default(),\n            url: row.get(\"url\")?,\n            sort_priority: row.get(\"sort_priority\")?,\n            metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(),\n        })\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum GrpcConnectionState {\n    Initialized,\n    Connected,\n    Closed,\n}\n\nimpl Default for GrpcConnectionState {\n    fn default() -> Self {\n        Self::Initialized\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"grpc_connections\")]\npub struct GrpcConnection {\n    #[ts(type = \"\\\"grpc_connection\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub workspace_id: String,\n    pub request_id: String,\n\n    pub elapsed: i32,\n    pub error: Option<String>,\n    pub method: String,\n    pub service: String,\n    pub status: i32,\n    pub state: GrpcConnectionState,\n    pub trailers: BTreeMap<String, String>,\n    pub url: String,\n}\n\nimpl UpsertModelInfo for GrpcConnection {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        GrpcConnectionIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        GrpcConnectionIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"gc\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (GrpcConnectionIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use GrpcConnectionIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (WorkspaceId, self.workspace_id.into()),\n            (RequestId, self.request_id.into()),\n            (Service, self.service.into()),\n            (Method, self.method.into()),\n            (Elapsed, self.elapsed.into()),\n            (State, serde_json::to_value(&self.state)?.as_str().into()),\n            (Status, self.status.into()),\n            (Error, self.error.as_ref().map(|s| s.as_str()).into()),\n            (Trailers, serde_json::to_string(&self.trailers)?.into()),\n            (Url, self.url.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            GrpcConnectionIden::UpdatedAt,\n            GrpcConnectionIden::Service,\n            GrpcConnectionIden::Method,\n            GrpcConnectionIden::Elapsed,\n            GrpcConnectionIden::Status,\n            GrpcConnectionIden::State,\n            GrpcConnectionIden::Error,\n            GrpcConnectionIden::Trailers,\n            GrpcConnectionIden::Url,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        let trailers: String = row.get(\"trailers\")?;\n        let state: String = row.get(\"state\")?;\n        Ok(Self {\n            id: row.get(\"id\")?,\n            model: row.get(\"model\")?,\n            workspace_id: row.get(\"workspace_id\")?,\n            request_id: row.get(\"request_id\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            service: row.get(\"service\")?,\n            method: row.get(\"method\")?,\n            elapsed: row.get(\"elapsed\")?,\n            state: serde_json::from_str(format!(r#\"\"{state}\"\"#).as_str()).unwrap(),\n            status: row.get(\"status\")?,\n            url: row.get(\"url\")?,\n            error: row.get(\"error\")?,\n            trailers: serde_json::from_str(trailers.as_str()).unwrap_or_default(),\n        })\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum GrpcEventType {\n    Info,\n    Error,\n    ClientMessage,\n    ServerMessage,\n    ConnectionStart,\n    ConnectionEnd,\n}\n\nimpl Default for GrpcEventType {\n    fn default() -> Self {\n        GrpcEventType::Info\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"grpc_events\")]\npub struct GrpcEvent {\n    #[ts(type = \"\\\"grpc_event\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub workspace_id: String,\n    pub request_id: String,\n    pub connection_id: String,\n\n    pub content: String,\n    pub error: Option<String>,\n    pub event_type: GrpcEventType,\n    pub metadata: BTreeMap<String, String>,\n    pub status: Option<i32>,\n}\n\nimpl UpsertModelInfo for GrpcEvent {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        GrpcEventIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        GrpcEventIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"ge\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (GrpcEventIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use GrpcEventIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (WorkspaceId, self.workspace_id.into()),\n            (RequestId, self.request_id.into()),\n            (ConnectionId, self.connection_id.into()),\n            (Content, self.content.into()),\n            (EventType, serde_json::to_string(&self.event_type)?.into()),\n            (Metadata, serde_json::to_string(&self.metadata)?.into()),\n            (Status, self.status.into()),\n            (Error, self.error.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            GrpcEventIden::UpdatedAt,\n            GrpcEventIden::Content,\n            GrpcEventIden::EventType,\n            GrpcEventIden::Metadata,\n            GrpcEventIden::Status,\n            GrpcEventIden::Error,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        let event_type: String = row.get(\"event_type\")?;\n        let metadata: String = row.get(\"metadata\")?;\n        Ok(Self {\n            id: row.get(\"id\")?,\n            model: row.get(\"model\")?,\n            workspace_id: row.get(\"workspace_id\")?,\n            request_id: row.get(\"request_id\")?,\n            connection_id: row.get(\"connection_id\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            content: row.get(\"content\")?,\n            event_type: serde_json::from_str(event_type.as_str()).unwrap_or_default(),\n            metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(),\n            status: row.get(\"status\")?,\n            error: row.get(\"error\")?,\n        })\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"plugins\")]\npub struct Plugin {\n    #[ts(type = \"\\\"plugin\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n\n    pub checked_at: Option<NaiveDateTime>,\n    pub directory: String,\n    pub enabled: bool,\n    pub url: Option<String>,\n    pub source: PluginSource,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum PluginSource {\n    Bundled,\n    Filesystem,\n    Registry,\n}\n\nimpl FromStr for PluginSource {\n    type Err = crate::error::Error;\n\n    fn from_str(s: &str) -> Result<Self> {\n        match s {\n            \"bundled\" => Ok(Self::Bundled),\n            \"filesystem\" => Ok(Self::Filesystem),\n            \"registry\" => Ok(Self::Registry),\n            _ => Ok(Self::default()),\n        }\n    }\n}\n\nimpl Display for PluginSource {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        let str = match self {\n            PluginSource::Bundled => \"bundled\".to_string(),\n            PluginSource::Filesystem => \"filesystem\".to_string(),\n            PluginSource::Registry => \"registry\".to_string(),\n        };\n        write!(f, \"{}\", str)\n    }\n}\n\nimpl Default for PluginSource {\n    fn default() -> Self {\n        Self::Filesystem\n    }\n}\n\nimpl UpsertModelInfo for Plugin {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        PluginIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        PluginIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"pg\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (PluginIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use PluginIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (CheckedAt, self.checked_at.into()),\n            (Directory, self.directory.into()),\n            (Url, self.url.into()),\n            (Enabled, self.enabled.into()),\n            (Source, self.source.to_string().into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            PluginIden::UpdatedAt,\n            PluginIden::CheckedAt,\n            PluginIden::Directory,\n            PluginIden::Url,\n            PluginIden::Enabled,\n            PluginIden::Source,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        Ok(Self {\n            id: row.get(\"id\")?,\n            model: row.get(\"model\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            checked_at: row.get(\"checked_at\")?,\n            url: row.get(\"url\")?,\n            directory: row.get(\"directory\")?,\n            enabled: row.get(\"enabled\")?,\n            source: PluginSource::from_str(row.get::<_, String>(\"source\")?.as_str()).unwrap(),\n        })\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"sync_states\")]\npub struct SyncState {\n    #[ts(type = \"\\\"sync_state\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub workspace_id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n    pub flushed_at: NaiveDateTime,\n\n    pub model_id: String,\n    pub checksum: String,\n    pub rel_path: String,\n    pub sync_dir: String,\n}\n\nimpl UpsertModelInfo for SyncState {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        SyncStateIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        SyncStateIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"ss\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (SyncStateIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use SyncStateIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (WorkspaceId, self.workspace_id.into()),\n            (FlushedAt, self.flushed_at.into()),\n            (Checksum, self.checksum.into()),\n            (ModelId, self.model_id.into()),\n            (RelPath, self.rel_path.into()),\n            (SyncDir, self.sync_dir.into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![\n            SyncStateIden::UpdatedAt,\n            SyncStateIden::FlushedAt,\n            SyncStateIden::Checksum,\n            SyncStateIden::RelPath,\n            SyncStateIden::SyncDir,\n        ]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        Ok(Self {\n            id: row.get(\"id\")?,\n            workspace_id: row.get(\"workspace_id\")?,\n            model: row.get(\"model\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            flushed_at: row.get(\"flushed_at\")?,\n            checksum: row.get(\"checksum\")?,\n            model_id: row.get(\"model_id\")?,\n            sync_dir: row.get(\"sync_dir\")?,\n            rel_path: row.get(\"rel_path\")?,\n        })\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"key_values\")]\npub struct KeyValue {\n    #[ts(type = \"\\\"key_value\\\"\")]\n    pub model: String,\n    pub id: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n\n    pub key: String,\n    pub namespace: String,\n    pub value: String,\n}\n\nimpl UpsertModelInfo for KeyValue {\n    fn table_name() -> impl IntoTableRef + IntoIden {\n        KeyValueIden::Table\n    }\n\n    fn id_column() -> impl IntoIden + Eq + Clone {\n        KeyValueIden::Id\n    }\n\n    fn generate_id() -> String {\n        generate_prefixed_id(\"kv\")\n    }\n\n    fn order_by() -> (impl IntoColumnRef, Order) {\n        (KeyValueIden::CreatedAt, Desc)\n    }\n\n    fn get_id(&self) -> String {\n        self.id.clone()\n    }\n\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {\n        use KeyValueIden::*;\n        Ok(vec![\n            (CreatedAt, upsert_date(source, self.created_at)),\n            (UpdatedAt, upsert_date(source, self.updated_at)),\n            (Namespace, self.namespace.clone().into()),\n            (Key, self.key.clone().into()),\n            (Value, self.value.clone().into()),\n        ])\n    }\n\n    fn update_columns() -> Vec<impl IntoIden> {\n        vec![KeyValueIden::UpdatedAt, KeyValueIden::Value]\n    }\n\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized,\n    {\n        Ok(Self {\n            id: row.get(\"id\")?,\n            model: row.get(\"model\")?,\n            created_at: row.get(\"created_at\")?,\n            updated_at: row.get(\"updated_at\")?,\n            namespace: row.get(\"namespace\")?,\n            key: row.get(\"key\")?,\n            value: row.get(\"value\")?,\n        })\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\n#[enum_def(table_name = \"plugin_key_values\")]\npub struct PluginKeyValue {\n    #[ts(type = \"\\\"plugin_key_value\\\"\")]\n    pub model: String,\n    pub created_at: NaiveDateTime,\n    pub updated_at: NaiveDateTime,\n\n    pub plugin_name: String,\n    pub key: String,\n    pub value: String,\n}\n\nimpl<'s> TryFrom<&Row<'s>> for PluginKeyValue {\n    type Error = rusqlite::Error;\n\n    fn try_from(r: &Row<'s>) -> std::result::Result<Self, Self::Error> {\n        Ok(Self {\n            model: r.get(\"model\")?,\n            created_at: r.get(\"created_at\")?,\n            updated_at: r.get(\"updated_at\")?,\n            plugin_name: r.get(\"plugin_name\")?,\n            key: r.get(\"key\")?,\n            value: r.get(\"value\")?,\n        })\n    }\n}\n\nfn default_true() -> bool {\n    true\n}\n\nfn default_http_method() -> String {\n    \"GET\".to_string()\n}\n\n#[macro_export]\nmacro_rules! define_any_model {\n    ($($type:ident),* $(,)?) => {\n        #[derive(Debug, Clone, Serialize, TS)]\n        #[serde(rename_all = \"camelCase\", untagged)]\n        #[ts(export, export_to = \"gen_models.ts\")]\n        pub enum AnyModel {\n            $(\n                $type($type),\n            )*\n        }\n\n        impl AnyModel {\n            #[inline]\n            pub fn id(&self) -> &str {\n                match self {\n                    $(\n                        AnyModel::$type(inner) => &inner.id,\n                    )*\n                }\n            }\n\n            #[inline]\n            pub fn model(&self) -> &str {\n                match self {\n                    $(\n                        AnyModel::$type(inner) => &inner.model,\n                    )*\n                }\n            }\n        }\n\n        $(\n            impl From<$type> for AnyModel {\n                fn from(value: $type) -> Self {\n                    AnyModel::$type(value)\n                }\n            }\n\n            impl From<AnyModel> for $type {\n                fn from(value: AnyModel) -> $type {\n                    match value {\n                        AnyModel::$type(inner) => inner,\n                        _ => panic!( // Should never happen because this macro also generates the enum variant\n                            \"Tried to convert AnyModel into `{}`, but found a different variant\",\n                            stringify!($type)\n                        ),\n                    }\n                }\n            }\n        )*\n    };\n}\n\ndefine_any_model! {\n    CookieJar,\n    Environment,\n    Folder,\n    GraphQlIntrospection,\n    GrpcConnection,\n    GrpcEvent,\n    GrpcRequest,\n    HttpRequest,\n    HttpResponse,\n    HttpResponseEvent,\n    KeyValue,\n    Plugin,\n    Settings,\n    SyncState,\n    WebsocketConnection,\n    WebsocketEvent,\n    WebsocketRequest,\n    Workspace,\n    WorkspaceMeta,\n}\n\nimpl<'de> Deserialize<'de> for AnyModel {\n    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>\n    where\n        D: Deserializer<'de>,\n    {\n        let value = Value::deserialize(deserializer)?;\n        let model = value.as_object().unwrap();\n        use AnyModel::*;\n        use serde_json::from_value as fv;\n\n        let model = match model.get(\"model\") {\n            Some(m) if m == \"cookie_jar\" => CookieJar(fv(value).unwrap()),\n            Some(m) if m == \"environment\" => Environment(fv(value).unwrap()),\n            Some(m) if m == \"folder\" => Folder(fv(value).unwrap()),\n            Some(m) if m == \"graphql_introspection\" => GraphQlIntrospection(fv(value).unwrap()),\n            Some(m) if m == \"grpc_connection\" => GrpcConnection(fv(value).unwrap()),\n            Some(m) if m == \"grpc_event\" => GrpcEvent(fv(value).unwrap()),\n            Some(m) if m == \"grpc_request\" => GrpcRequest(fv(value).unwrap()),\n            Some(m) if m == \"http_request\" => HttpRequest(fv(value).unwrap()),\n            Some(m) if m == \"http_response\" => HttpResponse(fv(value).unwrap()),\n            Some(m) if m == \"http_response_event\" => HttpResponseEvent(fv(value).unwrap()),\n            Some(m) if m == \"key_value\" => KeyValue(fv(value).unwrap()),\n            Some(m) if m == \"plugin\" => Plugin(fv(value).unwrap()),\n            Some(m) if m == \"settings\" => Settings(fv(value).unwrap()),\n            Some(m) if m == \"sync_state\" => SyncState(fv(value).unwrap()),\n            Some(m) if m == \"websocket_connection\" => WebsocketConnection(fv(value).unwrap()),\n            Some(m) if m == \"websocket_event\" => WebsocketEvent(fv(value).unwrap()),\n            Some(m) if m == \"websocket_request\" => WebsocketRequest(fv(value).unwrap()),\n            Some(m) if m == \"workspace\" => Workspace(fv(value).unwrap()),\n            Some(m) if m == \"workspace_meta\" => WorkspaceMeta(fv(value).unwrap()),\n            Some(m) => {\n                return Err(serde::de::Error::custom(format!(\n                    \"Failed to deserialize AnyModel {}\",\n                    m\n                )));\n            }\n            None => {\n                return Err(serde::de::Error::custom(\"Missing or invalid model\"));\n            }\n        };\n\n        Ok(model)\n    }\n}\n\nimpl AnyModel {\n    pub fn resolved_name(&self) -> String {\n        let compute_name = |name: &str, url: &str, fallback: &str| -> String {\n            if !name.is_empty() {\n                return name.to_string();\n            }\n            let without_variables = url.replace(r\"\\$\\{\\[\\s*([^\\]\\s]+)\\s*]}\", \"$1\");\n            if without_variables.is_empty() { fallback.to_string() } else { without_variables }\n        };\n\n        match self.clone() {\n            AnyModel::CookieJar(v) => v.name,\n            AnyModel::Environment(v) => v.name,\n            AnyModel::Folder(v) => v.name,\n            AnyModel::GrpcRequest(v) => compute_name(&v.name, &v.url, \"gRPC Request\"),\n            AnyModel::HttpRequest(v) => compute_name(&v.name, &v.url, \"HTTP Request\"),\n            AnyModel::WebsocketRequest(v) => compute_name(&v.name, &v.url, \"WebSocket Request\"),\n            AnyModel::Workspace(v) => v.name,\n            _ => \"No Name\".to_string(),\n        }\n    }\n}\n\npub trait UpsertModelInfo {\n    fn table_name() -> impl IntoTableRef + IntoIden;\n    fn id_column() -> impl IntoIden + Eq + Clone;\n    fn generate_id() -> String;\n    fn order_by() -> (impl IntoColumnRef, Order);\n    fn get_id(&self) -> String;\n    fn insert_values(\n        self,\n        source: &UpdateSource,\n    ) -> Result<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>>;\n    fn update_columns() -> Vec<impl IntoIden>;\n    fn from_row(row: &Row) -> rusqlite::Result<Self>\n    where\n        Self: Sized;\n}\n\n// Generate the created_at or updated_at timestamps for an upsert operation, depending on the ID\n// provided.\nfn upsert_date(update_source: &UpdateSource, dt: NaiveDateTime) -> SimpleExpr {\n    match update_source {\n        // Sync and import operations always preserve timestamps\n        UpdateSource::Sync | UpdateSource::Import => {\n            if dt.and_utc().timestamp() == 0 {\n                // Sometimes data won't have timestamps (partial data)\n                Utc::now().naive_utc().into()\n            } else {\n                dt.into()\n            }\n        }\n        // Other sources will always update to the latest time\n        _ => Utc::now().naive_utc().into(),\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/any_request.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{GrpcRequest, HttpRequest, WebsocketRequest};\n\npub enum AnyRequest {\n    HttpRequest(HttpRequest),\n    GrpcRequest(GrpcRequest),\n    WebsocketRequest(WebsocketRequest),\n}\n\nimpl<'a> DbContext<'a> {\n    pub fn get_any_request(&self, id: &str) -> Result<AnyRequest> {\n        if let Ok(http_request) = self.get_http_request(id) {\n            Ok(AnyRequest::HttpRequest(http_request))\n        } else if let Ok(grpc_request) = self.get_grpc_request(id) {\n            Ok(AnyRequest::GrpcRequest(grpc_request))\n        } else {\n            Ok(AnyRequest::WebsocketRequest(self.get_websocket_request(id)?))\n        }\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/batch.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace};\nuse crate::util::{BatchUpsertResult, UpdateSource};\nuse log::info;\n\nimpl<'a> DbContext<'a> {\n    pub fn batch_upsert(\n        &self,\n        workspaces: Vec<Workspace>,\n        environments: Vec<Environment>,\n        folders: Vec<Folder>,\n        http_requests: Vec<HttpRequest>,\n        grpc_requests: Vec<GrpcRequest>,\n        websocket_requests: Vec<WebsocketRequest>,\n        source: &UpdateSource,\n    ) -> Result<BatchUpsertResult> {\n        let mut imported_resources = BatchUpsertResult::default();\n\n        if workspaces.len() > 0 {\n            for v in workspaces {\n                let x = self.upsert_workspace(&v, source)?;\n                imported_resources.workspaces.push(x.clone());\n            }\n            info!(\"Upserted {} workspaces\", imported_resources.workspaces.len());\n        }\n\n        if http_requests.len() > 0 {\n            for v in http_requests {\n                let x = self.upsert_http_request(&v, source)?;\n                imported_resources.http_requests.push(x.clone());\n            }\n            info!(\"Upserted Imported {} http_requests\", imported_resources.http_requests.len());\n        }\n\n        if grpc_requests.len() > 0 {\n            for v in grpc_requests {\n                let x = self.upsert_grpc_request(&v, source)?;\n                imported_resources.grpc_requests.push(x.clone());\n            }\n            info!(\"Upserted {} grpc_requests\", imported_resources.grpc_requests.len());\n        }\n\n        if websocket_requests.len() > 0 {\n            for v in websocket_requests {\n                let x = self.upsert_websocket_request(&v, source)?;\n                imported_resources.websocket_requests.push(x.clone());\n            }\n            info!(\"Upserted {} websocket_requests\", imported_resources.websocket_requests.len());\n        }\n\n        // Do folders after their children so the UI doesn't render empty folders before populating\n        // immediately after.\n        if folders.len() > 0 {\n            for v in folders {\n                let x = self.upsert_folder(&v, source)?;\n                imported_resources.folders.push(x.clone());\n            }\n            info!(\"Upserted {} folders\", imported_resources.folders.len());\n        }\n\n        // Do environments last because they can depend on many models (requests, folders, etc)\n        if environments.len() > 0 {\n            for x in environments {\n                let x = self.upsert_environment(&x, source)?;\n                imported_resources.environments.push(x.clone());\n            }\n            info!(\"Upserted {} environments\", imported_resources.environments.len());\n        }\n\n        Ok(imported_resources)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/cookie_jars.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{CookieJar, CookieJarIden};\nuse crate::util::UpdateSource;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_cookie_jar(&self, id: &str) -> Result<CookieJar> {\n        self.find_one(CookieJarIden::Id, id)\n    }\n\n    pub fn list_cookie_jars(&self, workspace_id: &str) -> Result<Vec<CookieJar>> {\n        let mut cookie_jars = self.find_many(CookieJarIden::WorkspaceId, workspace_id, None)?;\n\n        if cookie_jars.is_empty() {\n            let jar = CookieJar {\n                name: \"Default\".to_string(),\n                workspace_id: workspace_id.to_string(),\n                ..Default::default()\n            };\n            cookie_jars.push(self.upsert_cookie_jar(&jar, &UpdateSource::Background)?);\n        }\n\n        Ok(cookie_jars)\n    }\n\n    pub fn delete_cookie_jar(\n        &self,\n        cookie_jar: &CookieJar,\n        source: &UpdateSource,\n    ) -> Result<CookieJar> {\n        self.delete(cookie_jar, source)\n    }\n\n    pub fn delete_cookie_jar_by_id(&self, id: &str, source: &UpdateSource) -> Result<CookieJar> {\n        let cookie_jar = self.get_cookie_jar(id)?;\n        self.delete_cookie_jar(&cookie_jar, source)\n    }\n\n    pub fn upsert_cookie_jar(\n        &self,\n        cookie_jar: &CookieJar,\n        source: &UpdateSource,\n    ) -> Result<CookieJar> {\n        self.upsert(cookie_jar, source)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/environments.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments};\nuse crate::error::Result;\nuse crate::models::{Environment, EnvironmentIden, EnvironmentVariable};\nuse crate::util::UpdateSource;\nuse log::{info, warn};\n\nimpl<'a> DbContext<'a> {\n    pub fn get_environment(&self, id: &str) -> Result<Environment> {\n        self.find_one(EnvironmentIden::Id, id)\n    }\n\n    pub fn get_environment_by_folder_id(&self, folder_id: &str) -> Result<Option<Environment>> {\n        let mut environments: Vec<Environment> =\n            self.find_many(EnvironmentIden::ParentId, folder_id, None)?;\n        // Sort so we return the most recently updated environment\n        environments.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));\n        Ok(environments.get(0).cloned())\n    }\n\n    pub fn get_base_environment(&self, workspace_id: &str) -> Result<Environment> {\n        let environments = self.list_environments_ensure_base(workspace_id)?;\n        let base_environments = environments\n            .into_iter()\n            .filter(|e| e.parent_model == \"workspace\")\n            .collect::<Vec<Environment>>();\n\n        if base_environments.len() > 1 {\n            return Err(MultipleBaseEnvironments(workspace_id.to_string()));\n        }\n\n        Ok(base_environments.first().cloned().ok_or(\n            // Should never happen because one should be created above if it does not exist\n            MissingBaseEnvironment(workspace_id.to_string()),\n        )?)\n    }\n\n    /// Lists environments and will create a base environment if one doesn't exist\n    pub fn list_environments_ensure_base(&self, workspace_id: &str) -> Result<Vec<Environment>> {\n        let mut environments = self.list_environments_dangerous(workspace_id)?;\n\n        let base_environment = environments.iter().find(|e| e.parent_model == \"workspace\");\n\n        if let None = base_environment {\n            let e = self.upsert_environment(\n                &Environment {\n                    workspace_id: workspace_id.to_string(),\n                    name: \"Global Variables\".to_string(),\n                    parent_model: \"workspace\".to_string(),\n                    ..Default::default()\n                },\n                &UpdateSource::Background,\n            )?;\n            info!(\"Created base environment {} for {workspace_id}\", e.id);\n            environments.push(e);\n        }\n\n        Ok(environments)\n    }\n\n    /// List environments for a workspace. Prefer list_environments_ensure_base()\n    fn list_environments_dangerous(&self, workspace_id: &str) -> Result<Vec<Environment>> {\n        Ok(self.find_many::<Environment>(EnvironmentIden::WorkspaceId, workspace_id, None)?)\n    }\n\n    pub fn delete_environment(\n        &self,\n        environment: &Environment,\n        source: &UpdateSource,\n    ) -> Result<Environment> {\n        let deleted_environment = self.delete(environment, source)?;\n\n        // Recreate the base environment if we happened to delete it\n        self.list_environments_ensure_base(&environment.workspace_id)?;\n\n        Ok(deleted_environment)\n    }\n\n    pub fn delete_environment_by_id(&self, id: &str, source: &UpdateSource) -> Result<Environment> {\n        let environment = self.get_environment(id)?;\n        self.delete_environment(&environment, source)\n    }\n\n    pub fn duplicate_environment(\n        &self,\n        environment: &Environment,\n        source: &UpdateSource,\n    ) -> Result<Environment> {\n        let mut environment = environment.clone();\n        environment.id = \"\".to_string();\n        self.upsert_environment(&environment, source)\n    }\n\n    /// Find other environments with the same parent folder\n    fn list_duplicate_folder_environments(&self, environment: &Environment) -> Vec<Environment> {\n        if environment.parent_model != \"folder\" {\n            return Vec::new();\n        }\n\n        self.list_environments_dangerous(&environment.workspace_id)\n            .unwrap_or_default()\n            .into_iter()\n            .filter(|e| {\n                e.id != environment.id\n                    && e.parent_model == \"folder\"\n                    && e.parent_id == environment.parent_id\n            })\n            .collect()\n    }\n\n    pub fn upsert_environment(\n        &self,\n        environment: &Environment,\n        source: &UpdateSource,\n    ) -> Result<Environment> {\n        let cleaned_variables = environment\n            .variables\n            .iter()\n            .filter(|v| !v.name.is_empty() || !v.value.is_empty())\n            .cloned()\n            .collect::<Vec<EnvironmentVariable>>();\n\n        // Sometimes a new environment can be created via sync/import, so we'll just delete\n        // the others when that happens. Not the best, but it's good for now.\n        let duplicates = self.list_duplicate_folder_environments(environment);\n        for duplicate in duplicates {\n            warn!(\n                \"Deleting duplicate environment {} for folder {:?}\",\n                duplicate.id, environment.parent_id\n            );\n            _ = self.delete(&duplicate, source);\n        }\n\n        // Automatically update the environment name based on the folder name\n        let mut name = environment.name.clone();\n        match (environment.parent_model.as_str(), environment.parent_id.as_deref()) {\n            (\"folder\", Some(folder_id)) => {\n                if let Ok(folder) = self.get_folder(folder_id) {\n                    name = format!(\"{} Environment\", folder.name);\n                }\n            }\n            _ => {}\n        }\n\n        self.upsert(\n            &Environment { name, variables: cleaned_variables, ..environment.clone() },\n            source,\n        )\n    }\n\n    pub fn resolve_environments(\n        &self,\n        workspace_id: &str,\n        folder_id: Option<&str>,\n        active_environment_id: Option<&str>,\n    ) -> Result<Vec<Environment>> {\n        let mut environments = Vec::new();\n\n        if let Some(folder_id) = folder_id {\n            let folder = self.get_folder(folder_id)?;\n\n            // Add current folder's environment\n            if let Some(e) = self.get_environment_by_folder_id(folder_id)? {\n                environments.push(e);\n            };\n\n            // Recurse up\n            let ancestors = self.resolve_environments(\n                workspace_id,\n                folder.folder_id.as_deref(),\n                active_environment_id,\n            )?;\n            environments.extend(ancestors);\n        } else {\n            // Add active and base environments\n            if let Some(id) = active_environment_id {\n                if let Ok(e) = self.get_environment(&id) {\n                    // Add active sub environment\n                    environments.push(e);\n                };\n            };\n\n            // Add the base environment\n            environments.push(self.get_base_environment(workspace_id)?);\n        }\n\n        Ok(environments)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/folders.rs",
    "content": "use crate::connection_or_tx::ConnectionOrTx;\nuse crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{\n    Environment, EnvironmentIden, Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequest,\n    HttpRequestHeader, HttpRequestIden, WebsocketRequest, WebsocketRequestIden,\n};\nuse crate::util::UpdateSource;\nuse serde_json::Value;\nuse std::collections::BTreeMap;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_folder(&self, id: &str) -> Result<Folder> {\n        self.find_one(FolderIden::Id, id)\n    }\n\n    pub fn list_folders(&self, workspace_id: &str) -> Result<Vec<Folder>> {\n        self.find_many(FolderIden::WorkspaceId, workspace_id, None)\n    }\n\n    pub fn delete_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {\n        match self.conn {\n            ConnectionOrTx::Connection(_) => {}\n            ConnectionOrTx::Transaction(_) => {}\n        }\n\n        let fid = &folder.id;\n        for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {\n            self.delete_http_request(&m, source)?;\n        }\n\n        for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {\n            self.delete_grpc_request(&m, source)?;\n        }\n\n        for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {\n            self.delete_websocket_request(&m, source)?;\n        }\n\n        for e in self.find_many(EnvironmentIden::ParentId, fid, None)? {\n            self.delete_environment(&e, source)?;\n        }\n\n        // Recurse down into child folders\n        for folder in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {\n            self.delete_folder(&folder, source)?;\n        }\n\n        self.delete(folder, source)\n    }\n\n    pub fn delete_folder_by_id(&self, id: &str, source: &UpdateSource) -> Result<Folder> {\n        let folder = self.get_folder(id)?;\n        self.delete_folder(&folder, source)\n    }\n\n    pub fn upsert_folder(&self, folder: &Folder, source: &UpdateSource) -> Result<Folder> {\n        self.upsert(folder, source)\n    }\n\n    pub fn duplicate_folder(&self, src_folder: &Folder, source: &UpdateSource) -> Result<Folder> {\n        let fid = &src_folder.id;\n\n        let new_folder = self.upsert_folder(\n            &Folder {\n                id: \"\".into(),\n                sort_priority: src_folder.sort_priority + 0.001,\n                ..src_folder.clone()\n            },\n            source,\n        )?;\n\n        for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {\n            self.upsert_http_request(\n                &HttpRequest { id: \"\".into(), folder_id: Some(new_folder.id.clone()), ..m },\n                source,\n            )?;\n        }\n\n        for m in self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, fid, None)? {\n            self.upsert_websocket_request(\n                &WebsocketRequest { id: \"\".into(), folder_id: Some(new_folder.id.clone()), ..m },\n                source,\n            )?;\n        }\n\n        for m in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, fid, None)? {\n            self.upsert_grpc_request(\n                &GrpcRequest { id: \"\".into(), folder_id: Some(new_folder.id.clone()), ..m },\n                source,\n            )?;\n        }\n\n        for m in self.find_many::<Environment>(EnvironmentIden::ParentId, fid, None)? {\n            self.upsert_environment(\n                &Environment { id: \"\".into(), parent_id: Some(new_folder.id.clone()), ..m },\n                source,\n            )?;\n        }\n\n        for m in self.find_many::<Folder>(FolderIden::FolderId, fid, None)? {\n            // Recurse down\n            self.duplicate_folder(&Folder { folder_id: Some(new_folder.id.clone()), ..m }, source)?;\n        }\n\n        Ok(new_folder)\n    }\n\n    pub fn resolve_auth_for_folder(\n        &self,\n        folder: &Folder,\n    ) -> Result<(Option<String>, BTreeMap<String, Value>, String)> {\n        if let Some(at) = folder.authentication_type.clone() {\n            return Ok((Some(at), folder.authentication.clone(), folder.id.clone()));\n        }\n\n        if let Some(folder_id) = folder.folder_id.clone() {\n            let folder = self.get_folder(&folder_id)?;\n            return self.resolve_auth_for_folder(&folder);\n        }\n\n        let workspace = self.get_workspace(&folder.workspace_id)?;\n        Ok(self.resolve_auth_for_workspace(&workspace))\n    }\n\n    pub fn resolve_headers_for_folder(&self, folder: &Folder) -> Result<Vec<HttpRequestHeader>> {\n        let mut headers = Vec::new();\n\n        if let Some(folder_id) = folder.folder_id.clone() {\n            let parent_folder = self.get_folder(&folder_id)?;\n            let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;\n            // NOTE: Add parent headers first, so overrides are logical\n            headers.append(&mut folder_headers);\n        } else {\n            let workspace = self.get_workspace(&folder.workspace_id)?;\n            let mut workspace_headers = self.resolve_headers_for_workspace(&workspace);\n            headers.append(&mut workspace_headers);\n        }\n\n        headers.append(&mut folder.headers.clone());\n\n        Ok(headers)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/graphql_introspections.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{GraphQlIntrospection, GraphQlIntrospectionIden};\nuse crate::util::UpdateSource;\nuse chrono::{Duration, Utc};\nuse sea_query::{Expr, Query, SqliteQueryBuilder};\nuse sea_query_rusqlite::RusqliteBinder;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_graphql_introspection(&self, request_id: &str) -> Option<GraphQlIntrospection> {\n        self.find_optional(GraphQlIntrospectionIden::RequestId, request_id)\n    }\n\n    pub fn upsert_graphql_introspection(\n        &self,\n        workspace_id: &str,\n        request_id: &str,\n        content: Option<String>,\n        source: &UpdateSource,\n    ) -> Result<GraphQlIntrospection> {\n        // Clean up old ones every time a new one is upserted\n        self.delete_expired_graphql_introspections()?;\n\n        match self.get_graphql_introspection(request_id) {\n            None => self.upsert(\n                &GraphQlIntrospection {\n                    content,\n                    request_id: request_id.to_string(),\n                    workspace_id: workspace_id.to_string(),\n                    ..Default::default()\n                },\n                source,\n            ),\n            Some(introspection) => {\n                self.upsert(&GraphQlIntrospection { content, ..introspection }, source)\n            }\n        }\n    }\n\n    pub fn delete_expired_graphql_introspections(&self) -> Result<()> {\n        let cutoff = Utc::now().naive_utc() - Duration::days(7);\n        let (sql, params) = Query::delete()\n            .from_table(GraphQlIntrospectionIden::Table)\n            .cond_where(Expr::col(GraphQlIntrospectionIden::UpdatedAt).lt(cutoff))\n            .build_rusqlite(SqliteQueryBuilder);\n\n        let mut stmt = self.conn.resolve().prepare(sql.as_str())?;\n        stmt.execute(&*params.as_params())?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/grpc_connections.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{GrpcConnection, GrpcConnectionIden, GrpcConnectionState};\nuse crate::queries::MAX_HISTORY_ITEMS;\nuse crate::util::UpdateSource;\nuse log::debug;\nuse sea_query::{Expr, Query, SqliteQueryBuilder};\nuse sea_query_rusqlite::RusqliteBinder;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_grpc_connection(&self, id: &str) -> Result<GrpcConnection> {\n        self.find_one(GrpcConnectionIden::Id, id)\n    }\n\n    pub fn delete_all_grpc_connections_for_request(\n        &self,\n        request_id: &str,\n        source: &UpdateSource,\n    ) -> Result<()> {\n        let responses = self.list_grpc_connections_for_request(request_id, None)?;\n        for m in responses {\n            self.delete(&m, source)?;\n        }\n        Ok(())\n    }\n\n    pub fn delete_all_grpc_connections_for_workspace(\n        &self,\n        workspace_id: &str,\n        source: &UpdateSource,\n    ) -> Result<()> {\n        for m in self.list_grpc_connections(workspace_id)? {\n            self.delete(&m, source)?;\n        }\n        Ok(())\n    }\n\n    pub fn delete_grpc_connection(\n        &self,\n        m: &GrpcConnection,\n        source: &UpdateSource,\n    ) -> Result<GrpcConnection> {\n        self.delete(m, source)\n    }\n\n    pub fn delete_grpc_connection_by_id(\n        &self,\n        id: &str,\n        source: &UpdateSource,\n    ) -> Result<GrpcConnection> {\n        let grpc_connection = self.get_grpc_connection(id)?;\n        self.delete_grpc_connection(&grpc_connection, source)\n    }\n\n    pub fn list_grpc_connections_for_request(\n        &self,\n        request_id: &str,\n        limit: Option<u64>,\n    ) -> Result<Vec<GrpcConnection>> {\n        self.find_many(GrpcConnectionIden::RequestId, request_id, limit)\n    }\n\n    pub fn list_grpc_connections(&self, workspace_id: &str) -> Result<Vec<GrpcConnection>> {\n        self.find_many(GrpcConnectionIden::WorkspaceId, workspace_id, None)\n    }\n\n    pub fn cancel_pending_grpc_connections(&self) -> Result<()> {\n        let closed = serde_json::to_value(&GrpcConnectionState::Closed)?;\n        let (sql, params) = Query::update()\n            .table(GrpcConnectionIden::Table)\n            .values([(GrpcConnectionIden::State, closed.as_str().into())])\n            .cond_where(Expr::col(GrpcConnectionIden::State).ne(closed.as_str()))\n            .build_rusqlite(SqliteQueryBuilder);\n        let mut stmt = self.conn.prepare(sql.as_str())?;\n        stmt.execute(&*params.as_params())?;\n        Ok(())\n    }\n\n    pub fn upsert_grpc_connection(\n        &self,\n        grpc_connection: &GrpcConnection,\n        source: &UpdateSource,\n    ) -> Result<GrpcConnection> {\n        let connections =\n            self.list_grpc_connections_for_request(grpc_connection.request_id.as_str(), None)?;\n\n        for m in connections.iter().skip(MAX_HISTORY_ITEMS - 1) {\n            debug!(\"Deleting old gRPC connection {}\", grpc_connection.id);\n            self.delete_grpc_connection(&m, source)?;\n        }\n\n        self.upsert(grpc_connection, source)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/grpc_events.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{GrpcEvent, GrpcEventIden};\nuse crate::util::UpdateSource;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_grpc_events(&self, id: &str) -> Result<GrpcEvent> {\n        self.find_one(GrpcEventIden::Id, id)\n    }\n\n    pub fn list_grpc_events(&self, connection_id: &str) -> Result<Vec<GrpcEvent>> {\n        self.find_many(GrpcEventIden::ConnectionId, connection_id, None)\n    }\n\n    pub fn upsert_grpc_event(\n        &self,\n        grpc_event: &GrpcEvent,\n        source: &UpdateSource,\n    ) -> Result<GrpcEvent> {\n        self.upsert(grpc_event, source)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/grpc_requests.rs",
    "content": "use super::dedupe_headers;\nuse crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{Folder, FolderIden, GrpcRequest, GrpcRequestIden, HttpRequestHeader};\nuse crate::util::UpdateSource;\nuse serde_json::Value;\nuse std::collections::BTreeMap;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_grpc_request(&self, id: &str) -> Result<GrpcRequest> {\n        self.find_one(GrpcRequestIden::Id, id)\n    }\n\n    pub fn list_grpc_requests(&self, workspace_id: &str) -> Result<Vec<GrpcRequest>> {\n        self.find_many(GrpcRequestIden::WorkspaceId, workspace_id, None)\n    }\n\n    pub fn list_grpc_requests_for_folder_recursive(\n        &self,\n        folder_id: &str,\n    ) -> Result<Vec<GrpcRequest>> {\n        let mut children = Vec::new();\n        for folder in self.find_many::<Folder>(FolderIden::FolderId, folder_id, None)? {\n            children.extend(self.list_grpc_requests_for_folder_recursive(&folder.id)?);\n        }\n        for request in self.find_many::<GrpcRequest>(GrpcRequestIden::FolderId, folder_id, None)? {\n            children.push(request);\n        }\n        Ok(children)\n    }\n\n    pub fn delete_grpc_request(\n        &self,\n        m: &GrpcRequest,\n        source: &UpdateSource,\n    ) -> Result<GrpcRequest> {\n        self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?;\n        self.delete(m, source)\n    }\n\n    pub fn delete_grpc_request_by_id(\n        &self,\n        id: &str,\n        source: &UpdateSource,\n    ) -> Result<GrpcRequest> {\n        let request = self.get_grpc_request(id)?;\n        self.delete_grpc_request(&request, source)\n    }\n\n    pub fn duplicate_grpc_request(\n        &self,\n        grpc_request: &GrpcRequest,\n        source: &UpdateSource,\n    ) -> Result<GrpcRequest> {\n        let mut request = grpc_request.clone();\n        request.id = \"\".to_string();\n        request.sort_priority = request.sort_priority + 0.001;\n        self.upsert(&request, source)\n    }\n\n    pub fn upsert_grpc_request(\n        &self,\n        grpc_request: &GrpcRequest,\n        source: &UpdateSource,\n    ) -> Result<GrpcRequest> {\n        self.upsert(grpc_request, source)\n    }\n\n    pub fn resolve_auth_for_grpc_request(\n        &self,\n        grpc_request: &GrpcRequest,\n    ) -> Result<(Option<String>, BTreeMap<String, Value>, String)> {\n        if let Some(at) = grpc_request.authentication_type.clone() {\n            return Ok((Some(at), grpc_request.authentication.clone(), grpc_request.id.clone()));\n        }\n\n        if let Some(folder_id) = grpc_request.folder_id.clone() {\n            let folder = self.get_folder(&folder_id)?;\n            return self.resolve_auth_for_folder(&folder);\n        }\n\n        let workspace = self.get_workspace(&grpc_request.workspace_id)?;\n        Ok(self.resolve_auth_for_workspace(&workspace))\n    }\n\n    pub fn resolve_metadata_for_grpc_request(\n        &self,\n        grpc_request: &GrpcRequest,\n    ) -> Result<Vec<HttpRequestHeader>> {\n        // Resolved headers should be from furthest to closest ancestor, to override logically.\n        let mut metadata = Vec::new();\n\n        if let Some(folder_id) = grpc_request.folder_id.clone() {\n            let parent_folder = self.get_folder(&folder_id)?;\n            let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;\n            metadata.append(&mut folder_headers);\n        } else {\n            let workspace = self.get_workspace(&grpc_request.workspace_id)?;\n            let mut workspace_metadata = self.resolve_headers_for_workspace(&workspace);\n            metadata.append(&mut workspace_metadata);\n        }\n\n        metadata.append(&mut grpc_request.metadata.clone());\n\n        Ok(dedupe_headers(metadata))\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/http_requests.rs",
    "content": "use super::dedupe_headers;\nuse crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden};\nuse crate::util::UpdateSource;\nuse serde_json::Value;\nuse std::collections::BTreeMap;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_http_request(&self, id: &str) -> Result<HttpRequest> {\n        self.find_one(HttpRequestIden::Id, id)\n    }\n\n    pub fn list_http_requests(&self, workspace_id: &str) -> Result<Vec<HttpRequest>> {\n        self.find_many(HttpRequestIden::WorkspaceId, workspace_id, None)\n    }\n\n    pub fn delete_http_request(\n        &self,\n        m: &HttpRequest,\n        source: &UpdateSource,\n    ) -> Result<HttpRequest> {\n        self.delete_all_http_responses_for_request(m.id.as_str(), source)?;\n        self.delete(m, source)\n    }\n\n    pub fn delete_http_request_by_id(\n        &self,\n        id: &str,\n        source: &UpdateSource,\n    ) -> Result<HttpRequest> {\n        let http_request = self.get_http_request(id)?;\n        self.delete_http_request(&http_request, source)\n    }\n\n    pub fn duplicate_http_request(\n        &self,\n        http_request: &HttpRequest,\n        source: &UpdateSource,\n    ) -> Result<HttpRequest> {\n        let mut http_request = http_request.clone();\n        http_request.id = \"\".to_string();\n        http_request.sort_priority = http_request.sort_priority + 0.001;\n        self.upsert(&http_request, source)\n    }\n\n    pub fn upsert_http_request(\n        &self,\n        http_request: &HttpRequest,\n        source: &UpdateSource,\n    ) -> Result<HttpRequest> {\n        self.upsert(http_request, source)\n    }\n\n    pub fn resolve_auth_for_http_request(\n        &self,\n        http_request: &HttpRequest,\n    ) -> Result<(Option<String>, BTreeMap<String, Value>, String)> {\n        if let Some(at) = http_request.authentication_type.clone() {\n            return Ok((Some(at), http_request.authentication.clone(), http_request.id.clone()));\n        }\n\n        if let Some(folder_id) = http_request.folder_id.clone() {\n            let folder = self.get_folder(&folder_id)?;\n            return self.resolve_auth_for_folder(&folder);\n        }\n\n        let workspace = self.get_workspace(&http_request.workspace_id)?;\n        Ok(self.resolve_auth_for_workspace(&workspace))\n    }\n\n    pub fn resolve_headers_for_http_request(\n        &self,\n        http_request: &HttpRequest,\n    ) -> Result<Vec<HttpRequestHeader>> {\n        // Resolved headers should be from furthest to closest ancestor, to override logically.\n        let mut headers = Vec::new();\n\n        if let Some(folder_id) = http_request.folder_id.clone() {\n            let parent_folder = self.get_folder(&folder_id)?;\n            let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;\n            headers.append(&mut folder_headers);\n        } else {\n            let workspace = self.get_workspace(&http_request.workspace_id)?;\n            let mut workspace_headers = self.resolve_headers_for_workspace(&workspace);\n            headers.append(&mut workspace_headers);\n        }\n\n        headers.append(&mut http_request.headers.clone());\n\n        Ok(dedupe_headers(headers))\n    }\n\n    pub fn list_http_requests_for_folder_recursive(\n        &self,\n        folder_id: &str,\n    ) -> Result<Vec<HttpRequest>> {\n        let mut children = Vec::new();\n        for m in self.find_many::<Folder>(FolderIden::FolderId, folder_id, None)? {\n            children.extend(self.list_http_requests_for_folder_recursive(&m.id)?);\n        }\n        for m in self.find_many::<HttpRequest>(FolderIden::FolderId, folder_id, None)? {\n            children.push(m);\n        }\n        Ok(children)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/http_response_events.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{HttpResponseEvent, HttpResponseEventIden};\nuse crate::util::UpdateSource;\n\nimpl<'a> DbContext<'a> {\n    pub fn list_http_response_events(&self, response_id: &str) -> Result<Vec<HttpResponseEvent>> {\n        self.find_many(HttpResponseEventIden::ResponseId, response_id, None)\n    }\n\n    pub fn upsert_http_response_event(\n        &self,\n        http_response_event: &HttpResponseEvent,\n        source: &UpdateSource,\n    ) -> Result<HttpResponseEvent> {\n        self.upsert(http_response_event, source)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/http_responses.rs",
    "content": "use crate::blob_manager::BlobManager;\nuse crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{HttpResponse, HttpResponseIden, HttpResponseState};\nuse crate::queries::MAX_HISTORY_ITEMS;\nuse crate::util::UpdateSource;\nuse log::{debug, error};\nuse sea_query::{Expr, Query, SqliteQueryBuilder};\nuse sea_query_rusqlite::RusqliteBinder;\nuse std::fs;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_http_response(&self, id: &str) -> Result<HttpResponse> {\n        self.find_one(HttpResponseIden::Id, id)\n    }\n\n    pub fn list_http_responses_for_request(\n        &self,\n        request_id: &str,\n        limit: Option<u64>,\n    ) -> Result<Vec<HttpResponse>> {\n        self.find_many(HttpResponseIden::RequestId, request_id, limit)\n    }\n\n    pub fn list_http_responses(\n        &self,\n        workspace_id: &str,\n        limit: Option<u64>,\n    ) -> Result<Vec<HttpResponse>> {\n        self.find_many(HttpResponseIden::WorkspaceId, workspace_id, limit)\n    }\n\n    pub fn delete_all_http_responses_for_request(\n        &self,\n        request_id: &str,\n        source: &UpdateSource,\n    ) -> Result<()> {\n        let responses = self.list_http_responses_for_request(request_id, None)?;\n        for m in responses {\n            self.delete(&m, source)?;\n        }\n        Ok(())\n    }\n\n    pub fn delete_all_http_responses_for_workspace(\n        &self,\n        workspace_id: &str,\n        source: &UpdateSource,\n    ) -> Result<()> {\n        let responses =\n            self.find_many::<HttpResponse>(HttpResponseIden::WorkspaceId, workspace_id, None)?;\n        for m in responses {\n            self.delete(&m, source)?;\n        }\n        Ok(())\n    }\n\n    pub fn delete_http_response(\n        &self,\n        http_response: &HttpResponse,\n        source: &UpdateSource,\n        blob_manager: &BlobManager,\n    ) -> Result<HttpResponse> {\n        // Delete the body file if it exists\n        if let Some(p) = http_response.body_path.clone() {\n            if let Err(e) = fs::remove_file(p) {\n                error!(\"Failed to delete body file: {}\", e);\n            };\n        }\n\n        // Delete request body blobs (pattern: {response_id}.request)\n        let blob_ctx = blob_manager.connect();\n        let body_id = format!(\"{}.request\", http_response.id);\n        if let Err(e) = blob_ctx.delete_chunks(&body_id) {\n            error!(\"Failed to delete request body blobs: {}\", e);\n        }\n\n        Ok(self.delete(http_response, source)?)\n    }\n\n    pub fn upsert_http_response(\n        &self,\n        http_response: &HttpResponse,\n        source: &UpdateSource,\n        blob_manager: &BlobManager,\n    ) -> Result<HttpResponse> {\n        let responses = self.list_http_responses_for_request(&http_response.request_id, None)?;\n\n        for m in responses.iter().skip(MAX_HISTORY_ITEMS - 1) {\n            debug!(\"Deleting old HTTP response {}\", http_response.id);\n            self.delete_http_response(&m, source, blob_manager)?;\n        }\n\n        self.upsert(http_response, source)\n    }\n\n    pub fn cancel_pending_http_responses(&self) -> Result<()> {\n        let closed = serde_json::to_value(&HttpResponseState::Closed)?;\n        let (sql, params) = Query::update()\n            .table(HttpResponseIden::Table)\n            .values([(HttpResponseIden::State, closed.as_str().into())])\n            .cond_where(Expr::col(HttpResponseIden::State).ne(closed.as_str()))\n            .build_rusqlite(SqliteQueryBuilder);\n        let mut stmt = self.conn.prepare(sql.as_str())?;\n        stmt.execute(&*params.as_params())?;\n        Ok(())\n    }\n\n    pub fn update_http_response_if_id(\n        &self,\n        response: &HttpResponse,\n        source: &UpdateSource,\n    ) -> Result<HttpResponse> {\n        if response.id.is_empty() { Ok(response.clone()) } else { self.upsert(response, source) }\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/key_values.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{KeyValue, KeyValueIden, UpsertModelInfo};\nuse crate::util::UpdateSource;\nuse chrono::NaiveDateTime;\nuse log::error;\nuse sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder};\nuse sea_query_rusqlite::RusqliteBinder;\n\nimpl<'a> DbContext<'a> {\n    pub fn list_key_values(&self) -> Result<Vec<KeyValue>> {\n        let (sql, params) = Query::select()\n            .from(KeyValueIden::Table)\n            .column(Asterisk)\n            // Temporary clause to prevent bug when reverting to the previous version, before the\n            // ID column was added. A previous version will not know about ID and will create\n            // key/value entries that don't have one. This clause ensures they are not queried\n            // TODO: Add migration to delete key/values with NULL IDs later on, then remove this\n            .cond_where(Expr::col(KeyValueIden::Id).is_not_null())\n            .build_rusqlite(SqliteQueryBuilder);\n        let mut stmt = self.conn.prepare(sql.as_str())?;\n        let items = stmt.query_map(&*params.as_params(), KeyValue::from_row)?;\n        Ok(items.map(|v| v.unwrap()).collect())\n    }\n\n    pub fn get_key_value_str(&self, namespace: &str, key: &str, default: &str) -> String {\n        match self.get_key_value_raw(namespace, key) {\n            None => default.to_string(),\n            Some(v) => {\n                let result = serde_json::from_str(&v.value);\n                match result {\n                    Ok(v) => v,\n                    Err(e) => {\n                        error!(\"Failed to parse string key value: {}\", e);\n                        default.to_string()\n                    }\n                }\n            }\n        }\n    }\n\n    pub fn get_key_value_dte(\n        &self,\n        namespace: &str,\n        key: &str,\n        default: NaiveDateTime,\n    ) -> NaiveDateTime {\n        match self.get_key_value_raw(namespace, key) {\n            None => default,\n            Some(v) => {\n                let result = serde_json::from_str(&v.value);\n                match result {\n                    Ok(v) => v,\n                    Err(e) => {\n                        error!(\"Failed to parse date key value: {}\", e);\n                        default\n                    }\n                }\n            }\n        }\n    }\n\n    pub fn get_key_value_int(&self, namespace: &str, key: &str, default: i32) -> i32 {\n        match self.get_key_value_raw(namespace, key) {\n            None => default.clone(),\n            Some(v) => {\n                let result = serde_json::from_str(&v.value);\n                match result {\n                    Ok(v) => v,\n                    Err(e) => {\n                        error!(\"Failed to parse int key value: {}\", e);\n                        default.clone()\n                    }\n                }\n            }\n        }\n    }\n\n    pub fn get_key_value_raw(&self, namespace: &str, key: &str) -> Option<KeyValue> {\n        let (sql, params) = Query::select()\n            .from(KeyValueIden::Table)\n            .column(Asterisk)\n            .cond_where(\n                Cond::all()\n                    .add(Expr::col(KeyValueIden::Namespace).eq(namespace))\n                    .add(Expr::col(KeyValueIden::Key).eq(key)),\n            )\n            .build_rusqlite(SqliteQueryBuilder);\n        self.conn.resolve().query_row(sql.as_str(), &*params.as_params(), KeyValue::from_row).ok()\n    }\n\n    pub fn set_key_value_dte(\n        &self,\n        namespace: &str,\n        key: &str,\n        value: NaiveDateTime,\n        source: &UpdateSource,\n    ) -> (KeyValue, bool) {\n        let encoded = serde_json::to_string(&value).unwrap();\n        self.set_key_value_raw(namespace, key, &encoded, source)\n    }\n\n    pub fn set_key_value_str(\n        &self,\n        namespace: &str,\n        key: &str,\n        value: &str,\n        source: &UpdateSource,\n    ) -> (KeyValue, bool) {\n        let encoded = serde_json::to_string(&value).unwrap();\n        self.set_key_value_raw(namespace, key, &encoded, source)\n    }\n\n    pub fn set_key_value_int(\n        &self,\n        namespace: &str,\n        key: &str,\n        value: i32,\n        source: &UpdateSource,\n    ) -> (KeyValue, bool) {\n        let encoded = serde_json::to_string(&value).unwrap();\n        self.set_key_value_raw(namespace, key, &encoded, source)\n    }\n\n    pub fn set_key_value_raw(\n        &self,\n        namespace: &str,\n        key: &str,\n        value: &str,\n        source: &UpdateSource,\n    ) -> (KeyValue, bool) {\n        match self.get_key_value_raw(namespace, key) {\n            None => (\n                self.upsert_key_value(\n                    &KeyValue {\n                        namespace: namespace.to_string(),\n                        key: key.to_string(),\n                        value: value.to_string(),\n                        ..Default::default()\n                    },\n                    source,\n                )\n                .expect(\"Failed to create key value\"),\n                true,\n            ),\n            Some(kv) => (\n                self.upsert_key_value(&KeyValue { value: value.to_string(), ..kv }, source)\n                    .expect(\"Failed to update key value\"),\n                false,\n            ),\n        }\n    }\n\n    pub fn upsert_key_value(\n        &self,\n        key_value: &KeyValue,\n        source: &UpdateSource,\n    ) -> Result<KeyValue> {\n        self.upsert(key_value, source)\n    }\n\n    pub fn delete_key_value(\n        &self,\n        namespace: &str,\n        key: &str,\n        source: &UpdateSource,\n    ) -> Result<()> {\n        let kv = match self.get_key_value_raw(namespace, key) {\n            None => return Ok(()),\n            Some(m) => m,\n        };\n\n        self.delete(&kv, source)?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/mod.rs",
    "content": "pub mod any_request;\nmod batch;\nmod cookie_jars;\nmod environments;\nmod folders;\nmod graphql_introspections;\nmod grpc_connections;\nmod grpc_events;\nmod grpc_requests;\nmod http_requests;\nmod http_response_events;\nmod http_responses;\nmod key_values;\nmod model_changes;\nmod plugin_key_values;\nmod plugins;\nmod settings;\nmod sync_states;\nmod websocket_connections;\nmod websocket_events;\nmod websocket_requests;\nmod workspace_metas;\npub mod workspaces;\npub use model_changes::PersistedModelChange;\n\nconst MAX_HISTORY_ITEMS: usize = 20;\n\nuse crate::models::HttpRequestHeader;\nuse std::collections::HashMap;\n\n/// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value.\n/// Preserves the order of first occurrence for each header name.\npub(crate) fn dedupe_headers(headers: Vec<HttpRequestHeader>) -> Vec<HttpRequestHeader> {\n    let mut index_by_name: HashMap<String, usize> = HashMap::new();\n    let mut deduped: Vec<HttpRequestHeader> = Vec::new();\n    for header in headers {\n        let key = header.name.to_lowercase();\n        if let Some(&idx) = index_by_name.get(&key) {\n            deduped[idx] = header;\n        } else {\n            index_by_name.insert(key, deduped.len());\n            deduped.push(header);\n        }\n    }\n    deduped\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/model_changes.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::util::ModelPayload;\nuse rusqlite::params;\nuse rusqlite::types::Type;\n\n#[derive(Debug, Clone)]\npub struct PersistedModelChange {\n    pub id: i64,\n    pub created_at: String,\n    pub payload: ModelPayload,\n}\n\nimpl<'a> DbContext<'a> {\n    pub fn list_model_changes_after(\n        &self,\n        after_id: i64,\n        limit: usize,\n    ) -> Result<Vec<PersistedModelChange>> {\n        let mut stmt = self.conn.prepare(\n            r#\"\n                SELECT id, created_at, payload\n                FROM model_changes\n                WHERE id > ?1\n                ORDER BY id ASC\n                LIMIT ?2\n            \"#,\n        )?;\n\n        let items = stmt.query_map(params![after_id, limit as i64], |row| {\n            let id: i64 = row.get(0)?;\n            let created_at: String = row.get(1)?;\n            let payload_raw: String = row.get(2)?;\n            let payload = serde_json::from_str::<ModelPayload>(&payload_raw).map_err(|e| {\n                rusqlite::Error::FromSqlConversionFailure(2, Type::Text, Box::new(e))\n            })?;\n            Ok(PersistedModelChange { id, created_at, payload })\n        })?;\n\n        Ok(items.collect::<std::result::Result<Vec<_>, rusqlite::Error>>()?)\n    }\n\n    pub fn list_model_changes_since(\n        &self,\n        since_created_at: &str,\n        since_id: i64,\n        limit: usize,\n    ) -> Result<Vec<PersistedModelChange>> {\n        let mut stmt = self.conn.prepare(\n            r#\"\n                SELECT id, created_at, payload\n                FROM model_changes\n                WHERE created_at > ?1\n                   OR (created_at = ?1 AND id > ?2)\n                ORDER BY created_at ASC, id ASC\n                LIMIT ?3\n            \"#,\n        )?;\n\n        let items = stmt.query_map(params![since_created_at, since_id, limit as i64], |row| {\n            let id: i64 = row.get(0)?;\n            let created_at: String = row.get(1)?;\n            let payload_raw: String = row.get(2)?;\n            let payload = serde_json::from_str::<ModelPayload>(&payload_raw).map_err(|e| {\n                rusqlite::Error::FromSqlConversionFailure(2, Type::Text, Box::new(e))\n            })?;\n            Ok(PersistedModelChange { id, created_at, payload })\n        })?;\n\n        Ok(items.collect::<std::result::Result<Vec<_>, rusqlite::Error>>()?)\n    }\n\n    pub fn prune_model_changes_older_than_days(&self, days: i64) -> Result<usize> {\n        let offset = format!(\"-{days} days\");\n        Ok(self.conn.resolve().execute(\n            r#\"\n                DELETE FROM model_changes\n                WHERE created_at < STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW', ?1)\n            \"#,\n            params![offset],\n        )?)\n    }\n\n    pub fn prune_model_changes_older_than_hours(&self, hours: i64) -> Result<usize> {\n        let offset = format!(\"-{hours} hours\");\n        Ok(self.conn.resolve().execute(\n            r#\"\n                DELETE FROM model_changes\n                WHERE created_at < STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW', ?1)\n            \"#,\n            params![offset],\n        )?)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::init_in_memory;\n    use crate::models::Workspace;\n    use crate::util::{ModelChangeEvent, UpdateSource};\n    use serde_json::json;\n\n    #[test]\n    fn records_model_changes_for_upsert_and_delete() {\n        let (query_manager, _blob_manager, _rx) = init_in_memory().expect(\"Failed to init DB\");\n        let db = query_manager.connect();\n\n        let workspace = db\n            .upsert_workspace(\n                &Workspace {\n                    name: \"Changes Test\".to_string(),\n                    setting_follow_redirects: true,\n                    setting_validate_certificates: true,\n                    ..Default::default()\n                },\n                &UpdateSource::Sync,\n            )\n            .expect(\"Failed to upsert workspace\");\n\n        let created_changes = db.list_model_changes_after(0, 10).expect(\"Failed to list changes\");\n        assert_eq!(created_changes.len(), 1);\n        assert_eq!(created_changes[0].payload.model.id(), workspace.id);\n        assert_eq!(created_changes[0].payload.model.model(), \"workspace\");\n        assert!(matches!(\n            created_changes[0].payload.change,\n            ModelChangeEvent::Upsert { created: true }\n        ));\n        assert!(matches!(created_changes[0].payload.update_source, UpdateSource::Sync));\n\n        db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync)\n            .expect(\"Failed to delete workspace\");\n\n        let all_changes = db.list_model_changes_after(0, 10).expect(\"Failed to list changes\");\n        assert_eq!(all_changes.len(), 2);\n        assert!(matches!(all_changes[1].payload.change, ModelChangeEvent::Delete));\n        assert!(all_changes[1].id > all_changes[0].id);\n\n        let changes_after_first = db\n            .list_model_changes_after(all_changes[0].id, 10)\n            .expect(\"Failed to list changes after cursor\");\n        assert_eq!(changes_after_first.len(), 1);\n        assert!(matches!(changes_after_first[0].payload.change, ModelChangeEvent::Delete));\n    }\n\n    #[test]\n    fn prunes_old_model_changes() {\n        let (query_manager, _blob_manager, _rx) = init_in_memory().expect(\"Failed to init DB\");\n        let db = query_manager.connect();\n\n        db.upsert_workspace(\n            &Workspace {\n                name: \"Prune Test\".to_string(),\n                setting_follow_redirects: true,\n                setting_validate_certificates: true,\n                ..Default::default()\n            },\n            &UpdateSource::Sync,\n        )\n        .expect(\"Failed to upsert workspace\");\n\n        let changes = db.list_model_changes_after(0, 10).expect(\"Failed to list changes\");\n        assert_eq!(changes.len(), 1);\n\n        db.conn\n            .resolve()\n            .execute(\n                \"UPDATE model_changes SET created_at = '2000-01-01 00:00:00.000' WHERE id = ?1\",\n                params![changes[0].id],\n            )\n            .expect(\"Failed to age model change row\");\n\n        let pruned =\n            db.prune_model_changes_older_than_days(30).expect(\"Failed to prune model changes\");\n        assert_eq!(pruned, 1);\n        assert!(db.list_model_changes_after(0, 10).expect(\"Failed to list changes\").is_empty());\n    }\n\n    #[test]\n    fn list_model_changes_since_uses_timestamp_with_id_tiebreaker() {\n        let (query_manager, _blob_manager, _rx) = init_in_memory().expect(\"Failed to init DB\");\n        let db = query_manager.connect();\n\n        let workspace = db\n            .upsert_workspace(\n                &Workspace {\n                    name: \"Cursor Test\".to_string(),\n                    setting_follow_redirects: true,\n                    setting_validate_certificates: true,\n                    ..Default::default()\n                },\n                &UpdateSource::Sync,\n            )\n            .expect(\"Failed to upsert workspace\");\n        db.delete_workspace_by_id(&workspace.id, &UpdateSource::Sync)\n            .expect(\"Failed to delete workspace\");\n\n        let all = db.list_model_changes_after(0, 10).expect(\"Failed to list changes\");\n        assert_eq!(all.len(), 2);\n\n        let fixed_ts = \"2026-02-16 00:00:00.000\";\n        db.conn\n            .resolve()\n            .execute(\"UPDATE model_changes SET created_at = ?1\", params![fixed_ts])\n            .expect(\"Failed to normalize timestamps\");\n\n        let after_first =\n            db.list_model_changes_since(fixed_ts, all[0].id, 10).expect(\"Failed to query cursor\");\n        assert_eq!(after_first.len(), 1);\n        assert_eq!(after_first[0].id, all[1].id);\n    }\n\n    #[test]\n    fn prunes_old_model_changes_by_hours() {\n        let (query_manager, _blob_manager, _rx) = init_in_memory().expect(\"Failed to init DB\");\n        let db = query_manager.connect();\n\n        db.upsert_workspace(\n            &Workspace {\n                name: \"Prune Hour Test\".to_string(),\n                setting_follow_redirects: true,\n                setting_validate_certificates: true,\n                ..Default::default()\n            },\n            &UpdateSource::Sync,\n        )\n        .expect(\"Failed to upsert workspace\");\n\n        let changes = db.list_model_changes_after(0, 10).expect(\"Failed to list changes\");\n        assert_eq!(changes.len(), 1);\n\n        db.conn\n            .resolve()\n            .execute(\n                \"UPDATE model_changes SET created_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW', '-2 hours') WHERE id = ?1\",\n                params![changes[0].id],\n            )\n            .expect(\"Failed to age model change row\");\n\n        let pruned =\n            db.prune_model_changes_older_than_hours(1).expect(\"Failed to prune model changes\");\n        assert_eq!(pruned, 1);\n    }\n\n    #[test]\n    fn list_model_changes_deserializes_http_response_event_payload() {\n        let (query_manager, _blob_manager, _rx) = init_in_memory().expect(\"Failed to init DB\");\n        let db = query_manager.connect();\n\n        let payload = json!({\n            \"model\": {\n                \"model\": \"http_response_event\",\n                \"id\": \"re_test\",\n                \"createdAt\": \"2026-02-16T21:01:34.809162\",\n                \"updatedAt\": \"2026-02-16T21:01:34.809163\",\n                \"workspaceId\": \"wk_test\",\n                \"responseId\": \"rs_test\",\n                \"event\": {\n                    \"type\": \"info\",\n                    \"message\": \"hello\"\n                }\n            },\n            \"updateSource\": { \"type\": \"sync\" },\n            \"change\": { \"type\": \"upsert\", \"created\": false }\n        });\n\n        db.conn\n            .resolve()\n            .execute(\n                r#\"\n                INSERT INTO model_changes (model, model_id, change, update_source, payload)\n                VALUES (?1, ?2, ?3, ?4, ?5)\n                \"#,\n                params![\n                    \"http_response_event\",\n                    \"re_test\",\n                    r#\"{\"type\":\"upsert\",\"created\":false}\"#,\n                    r#\"{\"type\":\"sync\"}\"#,\n                    payload.to_string(),\n                ],\n            )\n            .expect(\"Failed to insert model change row\");\n\n        let changes = db.list_model_changes_after(0, 10).expect(\"Failed to list changes\");\n        assert_eq!(changes.len(), 1);\n        assert_eq!(changes[0].payload.model.model(), \"http_response_event\");\n        assert_eq!(changes[0].payload.model.id(), \"re_test\");\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/plugin_key_values.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{PluginKeyValue, PluginKeyValueIden};\nuse sea_query::Keyword::CurrentTimestamp;\nuse sea_query::{Asterisk, Cond, Expr, OnConflict, Query, SqliteQueryBuilder};\nuse sea_query_rusqlite::RusqliteBinder;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_plugin_key_value(&self, plugin_name: &str, key: &str) -> Option<PluginKeyValue> {\n        let (sql, params) = Query::select()\n            .from(PluginKeyValueIden::Table)\n            .column(Asterisk)\n            .cond_where(\n                Cond::all()\n                    .add(Expr::col(PluginKeyValueIden::PluginName).eq(plugin_name))\n                    .add(Expr::col(PluginKeyValueIden::Key).eq(key)),\n            )\n            .build_rusqlite(SqliteQueryBuilder);\n        self.conn.resolve().query_row(sql.as_str(), &*params.as_params(), |row| row.try_into()).ok()\n    }\n\n    pub fn set_plugin_key_value(\n        &self,\n        plugin_name: &str,\n        key: &str,\n        value: &str,\n    ) -> (PluginKeyValue, bool) {\n        let existing = self.get_plugin_key_value(plugin_name, key);\n\n        let (sql, params) = Query::insert()\n            .into_table(PluginKeyValueIden::Table)\n            .columns([\n                PluginKeyValueIden::CreatedAt,\n                PluginKeyValueIden::UpdatedAt,\n                PluginKeyValueIden::PluginName,\n                PluginKeyValueIden::Key,\n                PluginKeyValueIden::Value,\n            ])\n            .values_panic([\n                CurrentTimestamp.into(),\n                CurrentTimestamp.into(),\n                plugin_name.into(),\n                key.into(),\n                value.into(),\n            ])\n            .on_conflict(\n                OnConflict::new()\n                    .update_columns([PluginKeyValueIden::UpdatedAt, PluginKeyValueIden::Value])\n                    .to_owned(),\n            )\n            .returning_all()\n            .build_rusqlite(SqliteQueryBuilder);\n\n        let mut stmt =\n            self.conn.prepare(sql.as_str()).expect(\"Failed to prepare PluginKeyValue upsert\");\n        let m: PluginKeyValue = stmt\n            .query_row(&*params.as_params(), |row| row.try_into())\n            .expect(\"Failed to upsert KeyValue\");\n\n        (m, existing.is_none())\n    }\n\n    pub fn delete_plugin_key_value(&self, namespace: &str, key: &str) -> Result<bool> {\n        if let None = self.get_plugin_key_value(namespace, key) {\n            return Ok(false);\n        };\n\n        let (sql, params) = Query::delete()\n            .from_table(PluginKeyValueIden::Table)\n            .cond_where(\n                Cond::all()\n                    .add(Expr::col(PluginKeyValueIden::PluginName).eq(namespace))\n                    .add(Expr::col(PluginKeyValueIden::Key).eq(key)),\n            )\n            .build_rusqlite(SqliteQueryBuilder);\n        self.conn.execute(sql.as_str(), &*params.as_params())?;\n        Ok(true)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/plugins.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{Plugin, PluginIden};\nuse crate::util::UpdateSource;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_plugin(&self, id: &str) -> Result<Plugin> {\n        self.find_one(PluginIden::Id, id)\n    }\n\n    pub fn get_plugin_by_directory(&self, directory: &str) -> Option<Plugin> {\n        self.find_optional(PluginIden::Directory, directory)\n    }\n\n    pub fn list_plugins(&self) -> Result<Vec<Plugin>> {\n        self.find_all()\n    }\n\n    pub fn delete_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result<Plugin> {\n        self.delete(plugin, source)\n    }\n\n    pub fn delete_plugin_by_id(&self, id: &str, source: &UpdateSource) -> Result<Plugin> {\n        let plugin = self.get_plugin(id)?;\n        self.delete_plugin(&plugin, source)\n    }\n\n    pub fn upsert_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result<Plugin> {\n        let mut plugin_to_upsert = plugin.clone();\n        if let Some(existing) = self.get_plugin_by_directory(&plugin.directory) {\n            plugin_to_upsert.id = existing.id;\n        }\n        self.upsert(&plugin_to_upsert, source)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/settings.rs",
    "content": "use std::collections::HashMap;\n\nuse crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{EditorKeymap, Settings, SettingsIden};\nuse crate::util::UpdateSource;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_settings(&self) -> Settings {\n        let id = \"default\".to_string();\n\n        if let Some(s) = self.find_optional::<Settings>(SettingsIden::Id, &id) {\n            return s;\n        };\n\n        let settings = Settings {\n            model: \"settings\".to_string(),\n            id,\n            created_at: Default::default(),\n            updated_at: Default::default(),\n\n            appearance: \"system\".to_string(),\n            client_certificates: Vec::new(),\n            editor_font_size: 12,\n            editor_font: None,\n            editor_keymap: EditorKeymap::Default,\n            editor_soft_wrap: true,\n            interface_font_size: 14,\n            interface_scale: 1.0,\n            interface_font: None,\n            hide_window_controls: false,\n            use_native_titlebar: false,\n            open_workspace_new_window: None,\n            proxy: None,\n            theme_dark: \"yaak-dark\".to_string(),\n            theme_light: \"yaak-light\".to_string(),\n            update_channel: \"stable\".to_string(),\n            autoupdate: true,\n            colored_methods: false,\n            hide_license_badge: false,\n            auto_download_updates: true,\n            check_notifications: true,\n            hotkeys: HashMap::new(),\n        };\n        self.upsert(&settings, &UpdateSource::Background).expect(\"Failed to upsert settings\")\n    }\n\n    pub fn upsert_settings(&self, settings: &Settings, source: &UpdateSource) -> Result<Settings> {\n        self.upsert(settings, source)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/sync_states.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{SyncState, SyncStateIden, UpsertModelInfo};\nuse crate::util::UpdateSource;\nuse sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder};\nuse sea_query_rusqlite::RusqliteBinder;\nuse std::path::Path;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_sync_state(&self, id: &str) -> Result<SyncState> {\n        self.find_one(SyncStateIden::Id, id)\n    }\n\n    pub fn upsert_sync_state(&self, sync_state: &SyncState) -> Result<SyncState> {\n        self.upsert(sync_state, &UpdateSource::Sync)\n    }\n\n    pub fn list_sync_states_for_workspace(\n        &self,\n        workspace_id: &str,\n        sync_dir: &Path,\n    ) -> Result<Vec<SyncState>> {\n        let (sql, params) = Query::select()\n            .from(SyncStateIden::Table)\n            .column(Asterisk)\n            .cond_where(\n                Cond::all()\n                    .add(Expr::col(SyncStateIden::WorkspaceId).eq(workspace_id))\n                    .add(Expr::col(SyncStateIden::SyncDir).eq(sync_dir.to_string_lossy())),\n            )\n            .build_rusqlite(SqliteQueryBuilder);\n        let mut stmt = self.conn.prepare(sql.as_str())?;\n        let items = stmt.query_map(&*params.as_params(), SyncState::from_row)?;\n        Ok(items.map(|v| v.unwrap()).collect())\n    }\n\n    pub fn delete_sync_state(&self, sync_state: &SyncState) -> Result<SyncState> {\n        self.delete(sync_state, &UpdateSource::Sync)\n    }\n\n    pub fn delete_sync_state_by_id(&self, id: &str) -> Result<SyncState> {\n        let sync_state = self.get_sync_state(id)?;\n        self.delete_sync_state(&sync_state)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/websocket_connections.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{WebsocketConnection, WebsocketConnectionIden, WebsocketConnectionState};\nuse crate::queries::MAX_HISTORY_ITEMS;\nuse crate::util::UpdateSource;\nuse log::debug;\nuse sea_query::{Expr, Query, SqliteQueryBuilder};\nuse sea_query_rusqlite::RusqliteBinder;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_websocket_connection(&self, id: &str) -> Result<WebsocketConnection> {\n        self.find_one(WebsocketConnectionIden::Id, id)\n    }\n\n    pub fn delete_all_websocket_connections_for_request(\n        &self,\n        request_id: &str,\n        source: &UpdateSource,\n    ) -> Result<()> {\n        let responses = self.list_websocket_connections_for_request(request_id)?;\n        for m in responses {\n            self.delete(&m, source)?;\n        }\n        Ok(())\n    }\n\n    pub fn delete_all_websocket_connections_for_workspace(\n        &self,\n        workspace_id: &str,\n        source: &UpdateSource,\n    ) -> Result<()> {\n        let responses = self.list_websocket_connections(workspace_id)?;\n        for m in responses {\n            self.delete(&m, source)?;\n        }\n        Ok(())\n    }\n\n    pub fn list_websocket_connections(\n        &self,\n        workspace_id: &str,\n    ) -> Result<Vec<WebsocketConnection>> {\n        self.find_many(WebsocketConnectionIden::WorkspaceId, workspace_id, None)\n    }\n\n    pub fn list_websocket_connections_for_request(\n        &self,\n        request_id: &str,\n    ) -> Result<Vec<WebsocketConnection>> {\n        self.find_many(WebsocketConnectionIden::RequestId, request_id, None)\n    }\n\n    pub fn delete_websocket_connection(\n        &self,\n        websocket_connection: &WebsocketConnection,\n        source: &UpdateSource,\n    ) -> Result<WebsocketConnection> {\n        self.delete(websocket_connection, source)\n    }\n\n    pub fn delete_websocket_connection_by_id(\n        &self,\n        id: &str,\n        source: &UpdateSource,\n    ) -> Result<WebsocketConnection> {\n        let websocket_connection = self.get_websocket_connection(id)?;\n        self.delete_websocket_connection(&websocket_connection, source)\n    }\n\n    pub fn upsert_websocket_connection(\n        &self,\n        websocket_connection: &WebsocketConnection,\n        source: &UpdateSource,\n    ) -> Result<WebsocketConnection> {\n        let connections =\n            self.list_websocket_connections_for_request(&websocket_connection.request_id)?;\n\n        for m in connections.iter().skip(MAX_HISTORY_ITEMS - 1) {\n            debug!(\"Deleting old websocket connection {}\", websocket_connection.id);\n            self.delete_websocket_connection(&m, source)?;\n        }\n\n        self.upsert(websocket_connection, source)\n    }\n\n    pub fn cancel_pending_websocket_connections(&self) -> Result<()> {\n        let closed = serde_json::to_value(&WebsocketConnectionState::Closed)?;\n        let (sql, params) = Query::update()\n            .table(WebsocketConnectionIden::Table)\n            .values([(WebsocketConnectionIden::State, closed.as_str().into())])\n            .cond_where(Expr::col(WebsocketConnectionIden::State).ne(closed.as_str()))\n            .build_rusqlite(SqliteQueryBuilder);\n        let mut stmt = self.conn.prepare(sql.as_str())?;\n        stmt.execute(&*params.as_params())?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/websocket_events.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{WebsocketEvent, WebsocketEventIden};\nuse crate::util::UpdateSource;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_websocket_event(&self, id: &str) -> Result<WebsocketEvent> {\n        self.find_one(WebsocketEventIden::Id, id)\n    }\n\n    pub fn list_websocket_events(&self, connection_id: &str) -> Result<Vec<WebsocketEvent>> {\n        self.find_many(WebsocketEventIden::ConnectionId, connection_id, None)\n    }\n\n    pub fn upsert_websocket_event(\n        &self,\n        websocket_event: &WebsocketEvent,\n        source: &UpdateSource,\n    ) -> Result<WebsocketEvent> {\n        self.upsert(websocket_event, source)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/websocket_requests.rs",
    "content": "use super::dedupe_headers;\nuse crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{\n    Folder, FolderIden, HttpRequestHeader, WebsocketRequest, WebsocketRequestIden,\n};\nuse crate::util::UpdateSource;\nuse serde_json::Value;\nuse std::collections::BTreeMap;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_websocket_request(&self, id: &str) -> Result<WebsocketRequest> {\n        self.find_one(WebsocketRequestIden::Id, id)\n    }\n\n    pub fn list_websocket_requests(&self, workspace_id: &str) -> Result<Vec<WebsocketRequest>> {\n        self.find_many(WebsocketRequestIden::WorkspaceId, workspace_id, None)\n    }\n\n    pub fn list_websocket_requests_for_folder_recursive(\n        &self,\n        folder_id: &str,\n    ) -> Result<Vec<WebsocketRequest>> {\n        let mut children = Vec::new();\n        for folder in self.find_many::<Folder>(FolderIden::FolderId, folder_id, None)? {\n            children.extend(self.list_websocket_requests_for_folder_recursive(&folder.id)?);\n        }\n        for request in\n            self.find_many::<WebsocketRequest>(WebsocketRequestIden::FolderId, folder_id, None)?\n        {\n            children.push(request);\n        }\n        Ok(children)\n    }\n\n    pub fn delete_websocket_request(\n        &self,\n        websocket_request: &WebsocketRequest,\n        source: &UpdateSource,\n    ) -> Result<WebsocketRequest> {\n        self.delete_all_websocket_connections_for_request(websocket_request.id.as_str(), source)?;\n        self.delete(websocket_request, source)\n    }\n\n    pub fn delete_websocket_request_by_id(\n        &self,\n        id: &str,\n        source: &UpdateSource,\n    ) -> Result<WebsocketRequest> {\n        let request = self.get_websocket_request(id)?;\n        self.delete_websocket_request(&request, source)\n    }\n\n    pub fn duplicate_websocket_request(\n        &self,\n        websocket_request: &WebsocketRequest,\n        source: &UpdateSource,\n    ) -> Result<WebsocketRequest> {\n        let mut websocket_request = websocket_request.clone();\n        websocket_request.id = \"\".to_string();\n        websocket_request.sort_priority = websocket_request.sort_priority + 0.001;\n        self.upsert(&websocket_request, source)\n    }\n\n    pub fn upsert_websocket_request(\n        &self,\n        websocket_request: &WebsocketRequest,\n        source: &UpdateSource,\n    ) -> Result<WebsocketRequest> {\n        self.upsert(websocket_request, source)\n    }\n\n    pub fn resolve_auth_for_websocket_request(\n        &self,\n        websocket_request: &WebsocketRequest,\n    ) -> Result<(Option<String>, BTreeMap<String, Value>, String)> {\n        if let Some(at) = websocket_request.authentication_type.clone() {\n            return Ok((\n                Some(at),\n                websocket_request.authentication.clone(),\n                websocket_request.id.clone(),\n            ));\n        }\n\n        if let Some(folder_id) = websocket_request.folder_id.clone() {\n            let folder = self.get_folder(&folder_id)?;\n            return self.resolve_auth_for_folder(&folder);\n        }\n\n        let workspace = self.get_workspace(&websocket_request.workspace_id)?;\n        Ok(self.resolve_auth_for_workspace(&workspace))\n    }\n\n    pub fn resolve_headers_for_websocket_request(\n        &self,\n        websocket_request: &WebsocketRequest,\n    ) -> Result<Vec<HttpRequestHeader>> {\n        let workspace = self.get_workspace(&websocket_request.workspace_id)?;\n\n        // Resolved headers should be from furthest to closest ancestor, to override logically.\n        let mut headers = Vec::new();\n\n        headers.append(&mut workspace.headers.clone());\n\n        if let Some(folder_id) = websocket_request.folder_id.clone() {\n            let parent_folder = self.get_folder(&folder_id)?;\n            let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;\n            headers.append(&mut folder_headers);\n        } else {\n            let workspace = self.get_workspace(&websocket_request.workspace_id)?;\n            let mut workspace_headers = self.resolve_headers_for_workspace(&workspace);\n            headers.append(&mut workspace_headers);\n        }\n\n        headers.append(&mut websocket_request.headers.clone());\n\n        Ok(dedupe_headers(headers))\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/workspace_metas.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{WorkspaceMeta, WorkspaceMetaIden};\nuse crate::util::UpdateSource;\nuse log::info;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_workspace_meta(&self, workspace_id: &str) -> Option<WorkspaceMeta> {\n        self.find_optional(WorkspaceMetaIden::WorkspaceId, workspace_id)\n    }\n\n    pub fn list_workspace_metas(&self, workspace_id: &str) -> Result<Vec<WorkspaceMeta>> {\n        let mut workspace_metas =\n            self.find_many(WorkspaceMetaIden::WorkspaceId, workspace_id, None)?;\n\n        if workspace_metas.is_empty() {\n            let wm = WorkspaceMeta { workspace_id: workspace_id.to_string(), ..Default::default() };\n            workspace_metas.push(self.upsert_workspace_meta(&wm, &UpdateSource::Background)?)\n        }\n\n        Ok(workspace_metas)\n    }\n\n    pub fn get_or_create_workspace_meta(&self, workspace_id: &str) -> Result<WorkspaceMeta> {\n        let workspace_meta = self.get_workspace_meta(workspace_id);\n        if let Some(workspace_meta) = workspace_meta {\n            return Ok(workspace_meta);\n        }\n\n        let workspace_meta =\n            WorkspaceMeta { workspace_id: workspace_id.to_string(), ..Default::default() };\n\n        info!(\"Creating WorkspaceMeta for {workspace_id}\");\n\n        self.upsert_workspace_meta(&workspace_meta, &UpdateSource::Background)\n    }\n\n    pub fn upsert_workspace_meta(\n        &self,\n        workspace_meta: &WorkspaceMeta,\n        source: &UpdateSource,\n    ) -> Result<WorkspaceMeta> {\n        self.upsert(workspace_meta, source)\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/queries/workspaces.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{\n    EnvironmentIden, FolderIden, GrpcRequestIden, HttpRequestHeader, HttpRequestIden,\n    WebsocketRequestIden, Workspace, WorkspaceIden,\n};\nuse crate::util::UpdateSource;\nuse serde_json::Value;\nuse std::collections::BTreeMap;\n\nimpl<'a> DbContext<'a> {\n    pub fn get_workspace(&self, id: &str) -> Result<Workspace> {\n        self.find_one(WorkspaceIden::Id, id)\n    }\n\n    pub fn list_workspaces(&self) -> Result<Vec<Workspace>> {\n        let mut workspaces = self.find_all()?;\n\n        if workspaces.is_empty() {\n            workspaces.push(self.upsert_workspace(\n                &Workspace {\n                    name: \"Yaak\".to_string(),\n                    setting_follow_redirects: true,\n                    setting_validate_certificates: true,\n                    ..Default::default()\n                },\n                &UpdateSource::Background,\n            )?)\n        }\n\n        Ok(workspaces)\n    }\n\n    pub fn delete_workspace(\n        &self,\n        workspace: &Workspace,\n        source: &UpdateSource,\n    ) -> Result<Workspace> {\n        for m in self.find_many(HttpRequestIden::WorkspaceId, &workspace.id, None)? {\n            self.delete_http_request(&m, source)?;\n        }\n\n        for m in self.find_many(GrpcRequestIden::WorkspaceId, &workspace.id, None)? {\n            self.delete_grpc_request(&m, source)?;\n        }\n\n        for m in self.find_many(WebsocketRequestIden::FolderId, &workspace.id, None)? {\n            self.delete_websocket_request(&m, source)?;\n        }\n\n        for m in self.find_many(FolderIden::WorkspaceId, &workspace.id, None)? {\n            self.delete_folder(&m, source)?;\n        }\n\n        for m in self.find_many(EnvironmentIden::WorkspaceId, &workspace.id, None)? {\n            self.delete_environment(&m, source)?;\n        }\n\n        self.delete(workspace, source)\n    }\n\n    pub fn delete_workspace_by_id(&self, id: &str, source: &UpdateSource) -> Result<Workspace> {\n        let workspace = self.get_workspace(id)?;\n        self.delete_workspace(&workspace, source)\n    }\n\n    pub fn upsert_workspace(&self, w: &Workspace, source: &UpdateSource) -> Result<Workspace> {\n        self.upsert(w, source)\n    }\n\n    pub fn resolve_auth_for_workspace(\n        &self,\n        workspace: &Workspace,\n    ) -> (Option<String>, BTreeMap<String, Value>, String) {\n        (\n            workspace.authentication_type.clone(),\n            workspace.authentication.clone(),\n            workspace.id.clone(),\n        )\n    }\n\n    pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {\n        let mut headers = default_headers();\n        headers.extend(workspace.headers.clone());\n        headers\n    }\n}\n\n/// Global default headers that are always sent with requests unless overridden.\n/// These are prepended to the inheritance chain so workspace/folder/request headers\n/// can override or disable them.\npub fn default_headers() -> Vec<HttpRequestHeader> {\n    vec![\n        HttpRequestHeader {\n            enabled: true,\n            name: \"User-Agent\".to_string(),\n            value: \"yaak\".to_string(),\n            id: None,\n        },\n        HttpRequestHeader {\n            enabled: true,\n            name: \"Accept\".to_string(),\n            value: \"*/*\".to_string(),\n            id: None,\n        },\n    ]\n}\n"
  },
  {
    "path": "crates/yaak-models/src/query_manager.rs",
    "content": "use crate::connection_or_tx::ConnectionOrTx;\nuse crate::db_context::DbContext;\nuse crate::error::Error::GenericError;\nuse crate::util::ModelPayload;\nuse r2d2::Pool;\nuse r2d2_sqlite::SqliteConnectionManager;\nuse rusqlite::TransactionBehavior;\nuse std::sync::{Arc, Mutex, mpsc};\n\n#[derive(Debug, Clone)]\npub struct QueryManager {\n    pool: Arc<Mutex<Pool<SqliteConnectionManager>>>,\n    events_tx: mpsc::Sender<ModelPayload>,\n}\n\nimpl QueryManager {\n    pub fn new(pool: Pool<SqliteConnectionManager>, events_tx: mpsc::Sender<ModelPayload>) -> Self {\n        QueryManager { pool: Arc::new(Mutex::new(pool)), events_tx }\n    }\n\n    pub fn connect(&self) -> DbContext<'_> {\n        let conn = self\n            .pool\n            .lock()\n            .expect(\"Failed to gain lock on DB\")\n            .get()\n            .expect(\"Failed to get a new DB connection from the pool\");\n        DbContext { _events_tx: self.events_tx.clone(), conn: ConnectionOrTx::Connection(conn) }\n    }\n\n    pub fn with_conn<F, T>(&self, func: F) -> T\n    where\n        F: FnOnce(&DbContext) -> T,\n    {\n        let conn = self\n            .pool\n            .lock()\n            .expect(\"Failed to gain lock on DB for transaction\")\n            .get()\n            .expect(\"Failed to get new DB connection from the pool\");\n\n        let db_context = DbContext {\n            _events_tx: self.events_tx.clone(),\n            conn: ConnectionOrTx::Connection(conn),\n        };\n\n        func(&db_context)\n    }\n\n    pub fn with_tx<T, E>(\n        &self,\n        func: impl FnOnce(&DbContext) -> std::result::Result<T, E>,\n    ) -> std::result::Result<T, E>\n    where\n        E: From<crate::error::Error>,\n    {\n        let mut conn = self\n            .pool\n            .lock()\n            .expect(\"Failed to gain lock on DB for transaction\")\n            .get()\n            .expect(\"Failed to get new DB connection from the pool\");\n        let tx = conn\n            .transaction_with_behavior(TransactionBehavior::Immediate)\n            .expect(\"Failed to start DB transaction\");\n\n        let db_context = DbContext {\n            _events_tx: self.events_tx.clone(),\n            conn: ConnectionOrTx::Transaction(&tx),\n        };\n\n        match func(&db_context) {\n            Ok(val) => {\n                tx.commit()\n                    .map_err(|e| GenericError(format!(\"Failed to commit transaction {e:?}\")))?;\n                Ok(val)\n            }\n            Err(e) => {\n                tx.rollback()\n                    .map_err(|e| GenericError(format!(\"Failed to rollback transaction {e:?}\")))?;\n                Err(e)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/yaak-models/src/render.rs",
    "content": "use crate::models::{Environment, EnvironmentVariable};\nuse std::collections::HashMap;\n\npub fn make_vars_hashmap(environment_chain: Vec<Environment>) -> HashMap<String, String> {\n    let mut variables = HashMap::new();\n\n    for e in environment_chain.iter().rev() {\n        variables = add_variable_to_map(variables, &e.variables);\n    }\n\n    variables\n}\n\nfn add_variable_to_map(\n    m: HashMap<String, String>,\n    variables: &Vec<EnvironmentVariable>,\n) -> HashMap<String, String> {\n    let mut map = m.clone();\n    for variable in variables {\n        if !variable.enabled {\n            continue;\n        }\n        let name = variable.name.as_str();\n        let value = variable.value.as_str();\n        map.insert(name.into(), value.into());\n    }\n\n    map\n}\n"
  },
  {
    "path": "crates/yaak-models/src/util.rs",
    "content": "use crate::db_context::DbContext;\nuse crate::error::Result;\nuse crate::models::{\n    AnyModel, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo, WebsocketRequest,\n    Workspace, WorkspaceIden,\n};\nuse chrono::{NaiveDateTime, Utc};\nuse nanoid::nanoid;\nuse serde::{Deserialize, Serialize};\nuse std::collections::BTreeMap;\nuse ts_rs::TS;\nuse yaak_core::WorkspaceContext;\n\npub fn generate_prefixed_id(prefix: &str) -> String {\n    format!(\"{prefix}_{}\", generate_id())\n}\n\npub fn generate_id() -> String {\n    generate_id_of_length(10)\n}\n\npub fn generate_id_of_length(n: usize) -> String {\n    let alphabet: [char; 57] = [\n        '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',\n        'k', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C',\n        'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W',\n        'X', 'Y', 'Z',\n    ];\n\n    nanoid!(n, &alphabet)\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub struct ModelPayload {\n    pub model: AnyModel,\n    pub update_source: UpdateSource,\n    pub change: ModelChangeEvent,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\", tag = \"type\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum ModelChangeEvent {\n    Upsert { created: bool },\n    Delete,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\", tag = \"type\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum UpdateSource {\n    Background,\n    Import,\n    Plugin,\n    Sync,\n    Window { label: String },\n}\n\nimpl UpdateSource {\n    pub fn from_window_label(label: impl Into<String>) -> Self {\n        Self::Window { label: label.into() }\n    }\n}\n\n#[derive(Default, Debug, Deserialize, Serialize)]\n#[serde(default, rename_all = \"camelCase\")]\npub struct WorkspaceExport {\n    pub yaak_version: String,\n    pub yaak_schema: i64,\n    pub timestamp: NaiveDateTime,\n    pub resources: BatchUpsertResult,\n}\n\n#[derive(Default, Debug, Deserialize, Serialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_util.ts\")]\npub struct BatchUpsertResult {\n    pub workspaces: Vec<Workspace>,\n    pub environments: Vec<Environment>,\n    pub folders: Vec<Folder>,\n    pub http_requests: Vec<HttpRequest>,\n    pub grpc_requests: Vec<GrpcRequest>,\n    pub websocket_requests: Vec<WebsocketRequest>,\n}\n\npub fn get_workspace_export_resources(\n    db: &DbContext,\n    yaak_version: &str,\n    workspace_ids: Vec<&str>,\n    include_private_environments: bool,\n) -> Result<WorkspaceExport> {\n    let mut data = WorkspaceExport {\n        yaak_version: yaak_version.to_string(),\n        yaak_schema: 4,\n        timestamp: Utc::now().naive_utc(),\n        resources: BatchUpsertResult {\n            workspaces: Vec::new(),\n            environments: Vec::new(),\n            folders: Vec::new(),\n            http_requests: Vec::new(),\n            grpc_requests: Vec::new(),\n            websocket_requests: Vec::new(),\n        },\n    };\n\n    for workspace_id in workspace_ids {\n        data.resources.workspaces.push(db.find_one(WorkspaceIden::Id, workspace_id)?);\n        data.resources.environments.append(\n            &mut db\n                .list_environments_ensure_base(workspace_id)?\n                .into_iter()\n                .filter(|e| include_private_environments || e.public)\n                .collect(),\n        );\n        data.resources.folders.append(&mut db.list_folders(workspace_id)?);\n        data.resources.http_requests.append(&mut db.list_http_requests(workspace_id)?);\n        data.resources.grpc_requests.append(&mut db.list_grpc_requests(workspace_id)?);\n        data.resources.websocket_requests.append(&mut db.list_websocket_requests(workspace_id)?);\n    }\n\n    Ok(data)\n}\n\npub fn maybe_gen_id<M: UpsertModelInfo>(\n    ctx: &WorkspaceContext,\n    id: &str,\n    ids: &mut BTreeMap<String, String>,\n) -> String {\n    if id == \"CURRENT_WORKSPACE\" {\n        if let Some(wid) = &ctx.workspace_id {\n            return wid.to_string();\n        }\n    }\n\n    if !id.starts_with(\"GENERATE_ID::\") {\n        return id.to_string();\n    }\n\n    let unique_key = id.replace(\"GENERATE_ID\", \"\");\n    if let Some(existing) = ids.get(unique_key.as_str()) {\n        existing.to_string()\n    } else {\n        let new_id = M::generate_id();\n        ids.insert(unique_key, new_id.clone());\n        new_id\n    }\n}\n\npub fn maybe_gen_id_opt<M: UpsertModelInfo>(\n    ctx: &WorkspaceContext,\n    id: Option<String>,\n    ids: &mut BTreeMap<String, String>,\n) -> Option<String> {\n    match id {\n        Some(id) => Some(maybe_gen_id::<M>(ctx, id.as_str(), ids)),\n        None => None,\n    }\n}\n"
  },
  {
    "path": "crates/yaak-plugins/Cargo.toml",
    "content": "[package]\nname = \"yaak-plugins\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nbase64 = \"0.22.1\"\nchrono = { workspace = true }\ndunce = \"1.0.4\"\nfutures-util = \"0.3.30\"\nhex = { workspace = true }\nkeyring = { workspace = true, features = [\"apple-native\", \"windows-native\", \"sync-secret-service\"] }\nlog = { workspace = true }\nmd5 = \"0.7.0\"\npath-slash = \"0.2.1\"\nrand = \"0.9.0\"\nreqwest = { workspace = true, features = [\"json\"] }\nserde = { workspace = true, features = [\"derive\"] }\nserde_json = { workspace = true }\nsha2 = { workspace = true }\nthiserror = { workspace = true }\ntokio = { workspace = true, features = [\"macros\", \"rt-multi-thread\", \"process\", \"fs\"] }\ntokio-tungstenite = \"0.26.1\"\nts-rs = { workspace = true }\nyaak-common = { workspace = true }\nyaak-crypto = { workspace = true }\nyaak-models = { workspace = true }\nyaak-templates = { workspace = true }\nzip-extract = \"0.4.0\"\n"
  },
  {
    "path": "crates/yaak-plugins/bindings/gen_api.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\nimport type { PluginVersion } from \"./gen_search\";\n\nexport type PluginNameVersion = { name: string, version: string, };\n\nexport type PluginSearchResponse = { plugins: Array<PluginVersion>, };\n\nexport type PluginUpdatesResponse = { plugins: Array<PluginNameVersion>, };\n"
  },
  {
    "path": "crates/yaak-plugins/bindings/gen_events.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\nimport type { AnyModel, Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest, Workspace } from \"./gen_models\";\nimport type { JsonValue } from \"./serde_json/JsonValue\";\n\nexport type BootRequest = { dir: string, watch: boolean, };\n\nexport type CallFolderActionArgs = { folder: Folder, };\n\nexport type CallFolderActionRequest = { index: number, pluginRefId: string, args: CallFolderActionArgs, };\n\nexport type CallGrpcRequestActionArgs = { grpcRequest: GrpcRequest, protoFiles: Array<string>, };\n\nexport type CallGrpcRequestActionRequest = { index: number, pluginRefId: string, args: CallGrpcRequestActionArgs, };\n\nexport type CallHttpAuthenticationActionArgs = { contextId: string, values: { [key in string]?: JsonPrimitive }, };\n\nexport type CallHttpAuthenticationActionRequest = { index: number, pluginRefId: string, args: CallHttpAuthenticationActionArgs, };\n\nexport type CallHttpAuthenticationRequest = { contextId: string, values: { [key in string]?: JsonPrimitive }, method: string, url: string, headers: Array<HttpHeader>, };\n\nexport type CallHttpAuthenticationResponse = {\n/**\n * HTTP headers to add to the request. Existing headers will be replaced, while\n * new headers will be added.\n */\nsetHeaders?: Array<HttpHeader>,\n/**\n * Query parameters to add to the request. Existing params will be replaced, while\n * new params will be added.\n */\nsetQueryParameters?: Array<HttpHeader>, };\n\nexport type CallHttpRequestActionArgs = { httpRequest: HttpRequest, };\n\nexport type CallHttpRequestActionRequest = { index: number, pluginRefId: string, args: CallHttpRequestActionArgs, };\n\nexport type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: JsonPrimitive }, };\n\nexport type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, };\n\nexport type CallTemplateFunctionResponse = { value: string | null, error?: string, };\n\nexport type CallWebsocketRequestActionArgs = { websocketRequest: WebsocketRequest, };\n\nexport type CallWebsocketRequestActionRequest = { index: number, pluginRefId: string, args: CallWebsocketRequestActionArgs, };\n\nexport type CallWorkspaceActionArgs = { workspace: Workspace, };\n\nexport type CallWorkspaceActionRequest = { index: number, pluginRefId: string, args: CallWorkspaceActionArgs, };\n\nexport type CloseWindowRequest = { label: string, };\n\nexport type Color = \"primary\" | \"secondary\" | \"info\" | \"success\" | \"notice\" | \"warning\" | \"danger\";\n\nexport type CompletionOptionType = \"constant\" | \"variable\";\n\nexport type Content = { \"type\": \"text\", content: string, } | { \"type\": \"markdown\", content: string, };\n\nexport type CopyTextRequest = { text: string, };\n\nexport type DeleteKeyValueRequest = { key: string, };\n\nexport type DeleteKeyValueResponse = { deleted: boolean, };\n\nexport type DeleteModelRequest = { model: string, id: string, };\n\nexport type DeleteModelResponse = { model: AnyModel, };\n\nexport type DialogSize = \"sm\" | \"md\" | \"lg\" | \"full\" | \"dynamic\";\n\nexport type EditorLanguage = \"text\" | \"javascript\" | \"json\" | \"html\" | \"xml\" | \"graphql\" | \"markdown\" | \"c\" | \"clojure\" | \"csharp\" | \"go\" | \"http\" | \"java\" | \"kotlin\" | \"objective_c\" | \"ocaml\" | \"php\" | \"powershell\" | \"python\" | \"r\" | \"ruby\" | \"shell\" | \"swift\";\n\nexport type EmptyPayload = {};\n\nexport type ErrorResponse = { error: string, };\n\nexport type ExportHttpRequestRequest = { httpRequest: HttpRequest, };\n\nexport type ExportHttpRequestResponse = { content: string, };\n\nexport type FileFilter = { name: string,\n/**\n * File extensions to require\n */\nextensions: Array<string>, };\n\nexport type FilterRequest = { content: string, filter: string, };\n\nexport type FilterResponse = { content: string, error?: string, };\n\nexport type FindHttpResponsesRequest = { requestId: string, limit?: number, };\n\nexport type FindHttpResponsesResponse = { httpResponses: Array<HttpResponse>, };\n\nexport type FolderAction = { label: string, icon?: Icon, };\n\nexport type FormInput = { \"type\": \"text\" } & FormInputText | { \"type\": \"editor\" } & FormInputEditor | { \"type\": \"select\" } & FormInputSelect | { \"type\": \"checkbox\" } & FormInputCheckbox | { \"type\": \"file\" } & FormInputFile | { \"type\": \"http_request\" } & FormInputHttpRequest | { \"type\": \"accordion\" } & FormInputAccordion | { \"type\": \"h_stack\" } & FormInputHStack | { \"type\": \"banner\" } & FormInputBanner | { \"type\": \"markdown\" } & FormInputMarkdown | { \"type\": \"key_value\" } & FormInputKeyValue;\n\nexport type FormInputAccordion = { label: string, inputs?: Array<FormInput>, hidden?: boolean, };\n\nexport type FormInputBanner = { inputs?: Array<FormInput>, hidden?: boolean, color?: Color, };\n\nexport type FormInputBase = {\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type FormInputCheckbox = {\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type FormInputEditor = {\n/**\n * Placeholder for the text input\n */\nplaceholder?: string | null,\n/**\n * Don't show the editor gutter (line numbers, folds, etc.)\n */\nhideGutter?: boolean,\n/**\n * Language for syntax highlighting\n */\nlanguage?: EditorLanguage, readOnly?: boolean,\n/**\n * Fixed number of visible rows\n */\nrows?: number, completionOptions?: Array<GenericCompletionOption>,\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type FormInputFile = {\n/**\n * The title of the file selection window\n */\ntitle: string,\n/**\n * Allow selecting multiple files\n */\nmultiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array<FileFilter>,\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type FormInputHStack = { inputs?: Array<FormInput>, hidden?: boolean, };\n\nexport type FormInputHttpRequest = {\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type FormInputKeyValue = {\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type FormInputMarkdown = { content: string, hidden?: boolean, };\n\nexport type FormInputSelect = {\n/**\n * The options that will be available in the select input\n */\noptions: Array<FormInputSelectOption>,\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type FormInputSelectOption = { label: string, value: string, };\n\nexport type FormInputText = {\n/**\n * Placeholder for the text input\n */\nplaceholder?: string | null,\n/**\n * Placeholder for the text input\n */\npassword?: boolean,\n/**\n * Whether to allow newlines in the input, like a <textarea/>\n */\nmultiLine?: boolean, completionOptions?: Array<GenericCompletionOption>,\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type GenericCompletionOption = { label: string, detail?: string, info?: string, type?: CompletionOptionType, boost?: number, };\n\nexport type GetCookieValueRequest = { name: string, };\n\nexport type GetCookieValueResponse = { value: string | null, };\n\nexport type GetFolderActionsResponse = { actions: Array<FolderAction>, pluginRefId: string, };\n\nexport type GetGrpcRequestActionsResponse = { actions: Array<GrpcRequestAction>, pluginRefId: string, };\n\nexport type GetHttpAuthenticationConfigRequest = { contextId: string, values: { [key in string]?: JsonPrimitive }, };\n\nexport type GetHttpAuthenticationConfigResponse = { args: Array<FormInput>, pluginRefId: string, actions?: Array<HttpAuthenticationAction>, };\n\nexport type GetHttpAuthenticationSummaryResponse = { name: string, label: string, shortLabel: string, };\n\nexport type GetHttpRequestActionsResponse = { actions: Array<HttpRequestAction>, pluginRefId: string, };\n\nexport type GetHttpRequestByIdRequest = { id: string, };\n\nexport type GetHttpRequestByIdResponse = { httpRequest: HttpRequest | null, };\n\nexport type GetKeyValueRequest = { key: string, };\n\nexport type GetKeyValueResponse = { value?: string, };\n\nexport type GetTemplateFunctionConfigRequest = { contextId: string, name: string, values: { [key in string]?: JsonPrimitive }, };\n\nexport type GetTemplateFunctionConfigResponse = { function: TemplateFunction, pluginRefId: string, };\n\nexport type GetTemplateFunctionSummaryResponse = { functions: Array<TemplateFunction>, pluginRefId: string, };\n\nexport type GetThemesRequest = Record<string, never>;\n\nexport type GetThemesResponse = { themes: Array<Theme>, };\n\nexport type GetWebsocketRequestActionsResponse = { actions: Array<WebsocketRequestAction>, pluginRefId: string, };\n\nexport type GetWorkspaceActionsResponse = { actions: Array<WorkspaceAction>, pluginRefId: string, };\n\nexport type GrpcRequestAction = { label: string, icon?: Icon, };\n\nexport type HttpAuthenticationAction = { label: string, icon?: Icon, };\n\nexport type HttpHeader = { name: string, value: string, };\n\nexport type HttpRequestAction = { label: string, icon?: Icon, };\n\nexport type Icon = \"alert_triangle\" | \"check\" | \"check_circle\" | \"chevron_down\" | \"copy\" | \"info\" | \"pin\" | \"search\" | \"trash\" | \"_unknown\";\n\nexport type ImportRequest = { content: string, };\n\nexport type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };\n\nexport type ImportResponse = { resources: ImportResources, };\n\nexport type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };\n\nexport type InternalEventPayload = { \"type\": \"boot_request\" } & BootRequest | { \"type\": \"boot_response\" } | { \"type\": \"reload_response\" } & ReloadResponse | { \"type\": \"terminate_request\" } | { \"type\": \"terminate_response\" } | { \"type\": \"import_request\" } & ImportRequest | { \"type\": \"import_response\" } & ImportResponse | { \"type\": \"filter_request\" } & FilterRequest | { \"type\": \"filter_response\" } & FilterResponse | { \"type\": \"export_http_request_request\" } & ExportHttpRequestRequest | { \"type\": \"export_http_request_response\" } & ExportHttpRequestResponse | { \"type\": \"send_http_request_request\" } & SendHttpRequestRequest | { \"type\": \"send_http_request_response\" } & SendHttpRequestResponse | { \"type\": \"list_cookie_names_request\" } & ListCookieNamesRequest | { \"type\": \"list_cookie_names_response\" } & ListCookieNamesResponse | { \"type\": \"get_cookie_value_request\" } & GetCookieValueRequest | { \"type\": \"get_cookie_value_response\" } & GetCookieValueResponse | { \"type\": \"get_http_request_actions_request\" } & EmptyPayload | { \"type\": \"get_http_request_actions_response\" } & GetHttpRequestActionsResponse | { \"type\": \"call_http_request_action_request\" } & CallHttpRequestActionRequest | { \"type\": \"get_websocket_request_actions_request\" } & EmptyPayload | { \"type\": \"get_websocket_request_actions_response\" } & GetWebsocketRequestActionsResponse | { \"type\": \"call_websocket_request_action_request\" } & CallWebsocketRequestActionRequest | { \"type\": \"get_workspace_actions_request\" } & EmptyPayload | { \"type\": \"get_workspace_actions_response\" } & GetWorkspaceActionsResponse | { \"type\": \"call_workspace_action_request\" } & CallWorkspaceActionRequest | { \"type\": \"get_folder_actions_request\" } & EmptyPayload | { \"type\": \"get_folder_actions_response\" } & GetFolderActionsResponse | { \"type\": \"call_folder_action_request\" } & CallFolderActionRequest | { \"type\": \"get_grpc_request_actions_request\" } & EmptyPayload | { \"type\": \"get_grpc_request_actions_response\" } & GetGrpcRequestActionsResponse | { \"type\": \"call_grpc_request_action_request\" } & CallGrpcRequestActionRequest | { \"type\": \"get_template_function_summary_request\" } & EmptyPayload | { \"type\": \"get_template_function_summary_response\" } & GetTemplateFunctionSummaryResponse | { \"type\": \"get_template_function_config_request\" } & GetTemplateFunctionConfigRequest | { \"type\": \"get_template_function_config_response\" } & GetTemplateFunctionConfigResponse | { \"type\": \"call_template_function_request\" } & CallTemplateFunctionRequest | { \"type\": \"call_template_function_response\" } & CallTemplateFunctionResponse | { \"type\": \"get_http_authentication_summary_request\" } & EmptyPayload | { \"type\": \"get_http_authentication_summary_response\" } & GetHttpAuthenticationSummaryResponse | { \"type\": \"get_http_authentication_config_request\" } & GetHttpAuthenticationConfigRequest | { \"type\": \"get_http_authentication_config_response\" } & GetHttpAuthenticationConfigResponse | { \"type\": \"call_http_authentication_request\" } & CallHttpAuthenticationRequest | { \"type\": \"call_http_authentication_response\" } & CallHttpAuthenticationResponse | { \"type\": \"call_http_authentication_action_request\" } & CallHttpAuthenticationActionRequest | { \"type\": \"call_http_authentication_action_response\" } & EmptyPayload | { \"type\": \"copy_text_request\" } & CopyTextRequest | { \"type\": \"copy_text_response\" } & EmptyPayload | { \"type\": \"render_http_request_request\" } & RenderHttpRequestRequest | { \"type\": \"render_http_request_response\" } & RenderHttpRequestResponse | { \"type\": \"render_grpc_request_request\" } & RenderGrpcRequestRequest | { \"type\": \"render_grpc_request_response\" } & RenderGrpcRequestResponse | { \"type\": \"template_render_request\" } & TemplateRenderRequest | { \"type\": \"template_render_response\" } & TemplateRenderResponse | { \"type\": \"get_key_value_request\" } & GetKeyValueRequest | { \"type\": \"get_key_value_response\" } & GetKeyValueResponse | { \"type\": \"set_key_value_request\" } & SetKeyValueRequest | { \"type\": \"set_key_value_response\" } & SetKeyValueResponse | { \"type\": \"delete_key_value_request\" } & DeleteKeyValueRequest | { \"type\": \"delete_key_value_response\" } & DeleteKeyValueResponse | { \"type\": \"open_window_request\" } & OpenWindowRequest | { \"type\": \"window_navigate_event\" } & WindowNavigateEvent | { \"type\": \"window_close_event\" } | { \"type\": \"close_window_request\" } & CloseWindowRequest | { \"type\": \"open_external_url_request\" } & OpenExternalUrlRequest | { \"type\": \"open_external_url_response\" } & EmptyPayload | { \"type\": \"show_toast_request\" } & ShowToastRequest | { \"type\": \"show_toast_response\" } & EmptyPayload | { \"type\": \"prompt_text_request\" } & PromptTextRequest | { \"type\": \"prompt_text_response\" } & PromptTextResponse | { \"type\": \"prompt_form_request\" } & PromptFormRequest | { \"type\": \"prompt_form_response\" } & PromptFormResponse | { \"type\": \"window_info_request\" } & WindowInfoRequest | { \"type\": \"window_info_response\" } & WindowInfoResponse | { \"type\": \"list_open_workspaces_request\" } & ListOpenWorkspacesRequest | { \"type\": \"list_open_workspaces_response\" } & ListOpenWorkspacesResponse | { \"type\": \"get_http_request_by_id_request\" } & GetHttpRequestByIdRequest | { \"type\": \"get_http_request_by_id_response\" } & GetHttpRequestByIdResponse | { \"type\": \"find_http_responses_request\" } & FindHttpResponsesRequest | { \"type\": \"find_http_responses_response\" } & FindHttpResponsesResponse | { \"type\": \"list_http_requests_request\" } & ListHttpRequestsRequest | { \"type\": \"list_http_requests_response\" } & ListHttpRequestsResponse | { \"type\": \"list_folders_request\" } & ListFoldersRequest | { \"type\": \"list_folders_response\" } & ListFoldersResponse | { \"type\": \"upsert_model_request\" } & UpsertModelRequest | { \"type\": \"upsert_model_response\" } & UpsertModelResponse | { \"type\": \"delete_model_request\" } & DeleteModelRequest | { \"type\": \"delete_model_response\" } & DeleteModelResponse | { \"type\": \"get_themes_request\" } & GetThemesRequest | { \"type\": \"get_themes_response\" } & GetThemesResponse | { \"type\": \"empty_response\" } & EmptyPayload | { \"type\": \"error_response\" } & ErrorResponse;\n\nexport type JsonPrimitive = string | number | boolean | null;\n\nexport type ListCookieNamesRequest = {};\n\nexport type ListCookieNamesResponse = { names: Array<string>, };\n\nexport type ListFoldersRequest = {};\n\nexport type ListFoldersResponse = { folders: Array<Folder>, };\n\nexport type ListHttpRequestsRequest = { folderId?: string, };\n\nexport type ListHttpRequestsResponse = { httpRequests: Array<HttpRequest>, };\n\nexport type ListOpenWorkspacesRequest = Record<string, never>;\n\nexport type ListOpenWorkspacesResponse = { workspaces: Array<WorkspaceInfo>, };\n\nexport type OpenExternalUrlRequest = { url: string, };\n\nexport type OpenWindowRequest = { url: string,\n/**\n * Label for the window. If not provided, a random one will be generated.\n */\nlabel: string, title?: string, size?: WindowSize, dataDirKey?: string, };\n\nexport type PluginContext = { id: string, label: string | null, workspaceId: string | null, };\n\nexport type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, size?: DialogSize, };\n\nexport type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };\n\nexport type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,\n/**\n * Text to add to the confirmation button\n */\nconfirmText?: string, password?: boolean,\n/**\n * Text to add to the cancel button\n */\ncancelText?: string,\n/**\n * Require the user to enter a non-empty value\n */\nrequired?: boolean, };\n\nexport type PromptTextResponse = { value: string | null, };\n\nexport type ReloadResponse = { silent: boolean, };\n\nexport type RenderGrpcRequestRequest = { grpcRequest: GrpcRequest, purpose: RenderPurpose, };\n\nexport type RenderGrpcRequestResponse = { grpcRequest: GrpcRequest, };\n\nexport type RenderHttpRequestRequest = { httpRequest: HttpRequest, purpose: RenderPurpose, };\n\nexport type RenderHttpRequestResponse = { httpRequest: HttpRequest, };\n\nexport type RenderPurpose = \"send\" | \"preview\";\n\nexport type SendHttpRequestRequest = { httpRequest: Partial<HttpRequest>, };\n\nexport type SendHttpRequestResponse = { httpResponse: HttpResponse, };\n\nexport type SetKeyValueRequest = { key: string, value: string, };\n\nexport type SetKeyValueResponse = {};\n\nexport type ShowToastRequest = { message: string, color?: Color, icon?: Icon, timeout?: number, };\n\nexport type TemplateFunction = { name: string, previewType?: TemplateFunctionPreviewType, description?: string,\n/**\n * Also support alternative names. This is useful for not breaking existing\n * tags when changing the `name` property\n */\naliases?: Array<string>, args: Array<TemplateFunctionArg>,\n/**\n * A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons).\n */\npreviewArgs?: Array<string>, };\n\n/**\n * Similar to FormInput, but contains\n */\nexport type TemplateFunctionArg = FormInput;\n\nexport type TemplateFunctionPreviewType = \"live\" | \"click\" | \"none\";\n\nexport type TemplateRenderRequest = { data: JsonValue, purpose: RenderPurpose, };\n\nexport type TemplateRenderResponse = { data: JsonValue, };\n\nexport type Theme = {\n/**\n * How the theme is identified. This should never be changed\n */\nid: string,\n/**\n * The friendly name of the theme to be displayed to the user\n */\nlabel: string,\n/**\n * Whether the theme will be used for dark or light appearance\n */\ndark: boolean,\n/**\n * The default top-level colors for the theme\n */\nbase: ThemeComponentColors,\n/**\n * Optionally override theme for individual UI components for more control\n */\ncomponents?: ThemeComponents, };\n\nexport type ThemeComponentColors = { surface?: string, surfaceHighlight?: string, surfaceActive?: string, text?: string, textSubtle?: string, textSubtlest?: string, border?: string, borderSubtle?: string, borderFocus?: string, shadow?: string, backdrop?: string, selection?: string, primary?: string, secondary?: string, info?: string, success?: string, notice?: string, warning?: string, danger?: string, };\n\nexport type ThemeComponents = { dialog?: ThemeComponentColors, menu?: ThemeComponentColors, toast?: ThemeComponentColors, sidebar?: ThemeComponentColors, responsePane?: ThemeComponentColors, appHeader?: ThemeComponentColors, button?: ThemeComponentColors, banner?: ThemeComponentColors, templateTag?: ThemeComponentColors, urlBar?: ThemeComponentColors, editor?: ThemeComponentColors, input?: ThemeComponentColors, };\n\nexport type UpsertModelRequest = { model: AnyModel, };\n\nexport type UpsertModelResponse = { model: AnyModel, };\n\nexport type WebsocketRequestAction = { label: string, icon?: Icon, };\n\nexport type WindowInfoRequest = { label: string, };\n\nexport type WindowInfoResponse = { requestId: string | null, environmentId: string | null, workspaceId: string | null, label: string, };\n\nexport type WindowNavigateEvent = { url: string, };\n\nexport type WindowSize = { width: number, height: number, };\n\nexport type WorkspaceAction = { label: string, icon?: Icon, };\n\nexport type WorkspaceInfo = { id: string, name: string, };\n"
  },
  {
    "path": "crates/yaak-plugins/bindings/gen_models.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;\n\nexport type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };\n\nexport type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], };\n\nexport type CookieDomain = { \"HostOnly\": string } | { \"Suffix\": string } | \"NotPresent\" | \"Empty\";\n\nexport type CookieExpires = { \"AtUtc\": string } | \"SessionEnd\";\n\nexport type CookieJar = { model: \"cookie_jar\", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };\n\nexport type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };\n\nexport type EditorKeymap = \"default\" | \"vim\" | \"vscode\" | \"emacs\";\n\nexport type EncryptedKey = { encryptedKey: string, };\n\nexport type Environment = { model: \"environment\", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, \n/**\n * Variables defined in this environment scope.\n * Child environments override parent variables by name.\n */\nvariables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };\n\nexport type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };\n\nexport type Folder = { model: \"folder\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };\n\nexport type GraphQlIntrospection = { model: \"graphql_introspection\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };\n\nexport type GrpcConnection = { model: \"grpc_connection\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };\n\nexport type GrpcConnectionState = \"initialized\" | \"connected\" | \"closed\";\n\nexport type GrpcEvent = { model: \"grpc_event\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };\n\nexport type GrpcEventType = \"info\" | \"error\" | \"client_message\" | \"server_message\" | \"connection_start\" | \"connection_end\";\n\nexport type GrpcRequest = { model: \"grpc_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, \n/**\n * Server URL (http for plaintext or https for secure)\n */\nurl: string, };\n\nexport type HttpRequest = { model: \"http_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, \n/**\n * URL parameters used for both path placeholders (`:id`) and query string entries.\n */\nurlParameters: Array<HttpUrlParameter>, };\n\nexport type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };\n\nexport type HttpResponse = { model: \"http_response\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };\n\nexport type HttpResponseEvent = { model: \"http_response_event\", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };\n\n/**\n * Serializable representation of HTTP response events for DB storage.\n * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.\n * The `From` impl is in yaak-http to avoid circular dependencies.\n */\nexport type HttpResponseEventData = { \"type\": \"setting\", name: string, value: string, } | { \"type\": \"info\", message: string, } | { \"type\": \"redirect\", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { \"type\": \"send_url\", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { \"type\": \"receive_url\", version: string, status: string, } | { \"type\": \"header_up\", name: string, value: string, } | { \"type\": \"header_down\", name: string, value: string, } | { \"type\": \"chunk_sent\", bytes: number, } | { \"type\": \"chunk_received\", bytes: number, } | { \"type\": \"dns_resolved\", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };\n\nexport type HttpResponseHeader = { name: string, value: string, };\n\nexport type HttpResponseState = \"initialized\" | \"connected\" | \"closed\";\n\nexport type HttpUrlParameter = { enabled?: boolean, \n/**\n * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`\n * Other entries are appended as query parameters\n */\nname: string, value: string, id?: string, };\n\nexport type KeyValue = { model: \"key_value\", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };\n\nexport type Plugin = { model: \"plugin\", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };\n\nexport type PluginSource = \"bundled\" | \"filesystem\" | \"registry\";\n\nexport type ProxySetting = { \"type\": \"enabled\", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { \"type\": \"disabled\" };\n\nexport type ProxySettingAuth = { user: string, password: string, };\n\nexport type Settings = { model: \"settings\", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array<string> }, };\n\nexport type SyncState = { model: \"sync_state\", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };\n\nexport type WebsocketConnection = { model: \"websocket_connection\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };\n\nexport type WebsocketConnectionState = \"initialized\" | \"connected\" | \"closing\" | \"closed\";\n\nexport type WebsocketEvent = { model: \"websocket_event\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };\n\nexport type WebsocketEventType = \"binary\" | \"close\" | \"frame\" | \"open\" | \"ping\" | \"pong\" | \"text\";\n\nexport type WebsocketRequest = { model: \"websocket_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, \n/**\n * URL parameters used for both path placeholders (`:id`) and query string entries.\n */\nurlParameters: Array<HttpUrlParameter>, };\n\nexport type Workspace = { model: \"workspace\", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };\n\nexport type WorkspaceMeta = { model: \"workspace_meta\", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };\n"
  },
  {
    "path": "crates/yaak-plugins/bindings/gen_search.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type PluginMetadata = { version: string, name: string, displayName: string, description: string | null, homepageUrl: string | null, repositoryUrl: string | null, };\n\nexport type PluginVersion = { id: string, version: string, url: string, description: string | null, name: string, displayName: string, homepageUrl: string | null, repositoryUrl: string | null, checksum: string, readme: string | null, yanked: boolean, };\n"
  },
  {
    "path": "crates/yaak-plugins/bindings/serde_json/JsonValue.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;\n"
  },
  {
    "path": "crates/yaak-plugins/index.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport { PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse } from \"./bindings/gen_api\";\n\nexport * from \"./bindings/gen_models\";\nexport * from \"./bindings/gen_events\";\nexport * from \"./bindings/gen_search\";\n\nexport async function searchPlugins(query: string) {\n  return invoke<PluginSearchResponse>(\"cmd_plugins_search\", { query });\n}\n\nexport async function installPlugin(name: string, version: string | null) {\n  return invoke<void>(\"cmd_plugins_install\", { name, version });\n}\n\nexport async function uninstallPlugin(pluginId: string) {\n  return invoke<void>(\"cmd_plugins_uninstall\", { pluginId });\n}\n\nexport async function checkPluginUpdates() {\n  return invoke<PluginUpdatesResponse>(\"cmd_plugins_updates\", {});\n}\n\nexport async function updateAllPlugins() {\n  return invoke<PluginNameVersion[]>(\"cmd_plugins_update_all\", {});\n}\n\nexport async function installPluginFromDirectory(directory: string) {\n  return invoke<void>(\"cmd_plugins_install_from_directory\", { directory });\n}\n"
  },
  {
    "path": "crates/yaak-plugins/package.json",
    "content": "{\n  \"name\": \"@yaakapp-internal/plugins\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"index.ts\"\n}\n"
  },
  {
    "path": "crates/yaak-plugins/src/api.rs",
    "content": "use crate::error::Error::ApiErr;\nuse crate::error::Result;\nuse crate::plugin_meta::get_plugin_meta;\nuse log::{info, warn};\nuse reqwest::{Client, Response, Url};\nuse serde::{Deserialize, Serialize};\nuse std::path::Path;\nuse std::str::FromStr;\nuse ts_rs::TS;\nuse yaak_models::models::{Plugin, PluginSource};\n\n/// Get plugin info from the registry.\npub async fn get_plugin(\n    http_client: &Client,\n    name: &str,\n    version: Option<String>,\n) -> Result<PluginVersion> {\n    info!(\"Getting plugin: {name} {version:?}\");\n    let mut url = build_url(&format!(\"/{name}\"));\n    if let Some(version) = version {\n        let mut query_pairs = url.query_pairs_mut();\n        query_pairs.append_pair(\"version\", &version);\n    };\n    let resp = http_client.get(url.clone()).send().await?;\n    if !resp.status().is_success() {\n        return Err(ApiErr(format!(\"{} response to {}\", resp.status(), url.to_string())));\n    }\n    Ok(resp.json().await?)\n}\n\n/// Download the plugin archive from the registry.\npub async fn download_plugin_archive(\n    http_client: &Client,\n    plugin_version: &PluginVersion,\n) -> Result<Response> {\n    let name = plugin_version.name.clone();\n    let version = plugin_version.version.clone();\n    info!(\"Downloading plugin: {name} {version}\");\n    let mut url = build_url(&format!(\"/{}/download\", name));\n    {\n        let mut query_pairs = url.query_pairs_mut();\n        query_pairs.append_pair(\"version\", &version);\n    };\n    let resp = http_client.get(url.clone()).send().await?;\n    if !resp.status().is_success() {\n        warn!(\"Failed to download plugin: {name} {version}\");\n        return Err(ApiErr(format!(\"{} response to {}\", resp.status(), url.to_string())));\n    }\n    info!(\"Downloaded plugin: {url}\");\n    Ok(resp)\n}\n\n/// Check for plugin updates.\n/// Takes a list of plugins to check against the registry.\npub async fn check_plugin_updates(\n    http_client: &Client,\n    plugins: Vec<Plugin>,\n) -> Result<PluginUpdatesResponse> {\n    let name_versions: Vec<PluginNameVersion> = plugins\n        .into_iter()\n        .filter(|p| matches!(p.source, PluginSource::Registry)) // Only check registry-installed plugins\n        .filter_map(|p| match get_plugin_meta(&Path::new(&p.directory)) {\n            Ok(m) => Some(PluginNameVersion { name: m.name, version: m.version }),\n            Err(e) => {\n                warn!(\"Failed to get plugin metadata: {}\", e);\n                None\n            }\n        })\n        .collect();\n\n    let url = build_url(\"/updates\");\n    let body = serde_json::to_vec(&PluginUpdatesResponse { plugins: name_versions })?;\n    let resp = http_client.post(url.clone()).body(body).send().await?;\n    if !resp.status().is_success() {\n        return Err(ApiErr(format!(\"{} response to {}\", resp.status(), url.to_string())));\n    }\n\n    let results: PluginUpdatesResponse = resp.json().await?;\n    Ok(results)\n}\n\n/// Search for plugins in the registry.\npub async fn search_plugins(http_client: &Client, query: &str) -> Result<PluginSearchResponse> {\n    let mut url = build_url(\"/search\");\n    {\n        let mut query_pairs = url.query_pairs_mut();\n        query_pairs.append_pair(\"query\", query);\n    };\n    let resp = http_client.get(url).send().await?;\n    Ok(resp.json().await?)\n}\n\nfn build_url(path: &str) -> Url {\n    let base_url = \"https://api.yaak.app/api/v1/plugins\";\n    Url::from_str(&format!(\"{base_url}{path}\")).unwrap()\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_search.ts\")]\npub struct PluginVersion {\n    pub id: String,\n    pub version: String,\n    pub url: String,\n    pub description: Option<String>,\n    pub name: String,\n    pub display_name: String,\n    pub homepage_url: Option<String>,\n    pub repository_url: Option<String>,\n    pub checksum: String,\n    pub readme: Option<String>,\n    pub yanked: bool,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_api.ts\")]\npub struct PluginSearchResponse {\n    pub plugins: Vec<PluginVersion>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_api.ts\")]\npub struct PluginNameVersion {\n    pub name: String,\n    pub version: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_api.ts\")]\npub struct PluginUpdatesResponse {\n    pub plugins: Vec<PluginNameVersion>,\n}\n"
  },
  {
    "path": "crates/yaak-plugins/src/checksum.rs",
    "content": "use sha2::{Digest, Sha256};\n\npub(crate) fn compute_checksum(bytes: impl AsRef<[u8]>) -> String {\n    let mut hasher = Sha256::new();\n    hasher.update(&bytes);\n    let hash = hasher.finalize();\n    hex::encode(hash)\n}\n"
  },
  {
    "path": "crates/yaak-plugins/src/error.rs",
    "content": "use crate::events::InternalEvent;\nuse serde::{Serialize, Serializer};\nuse thiserror::Error;\nuse tokio::io;\nuse tokio::sync::mpsc::error::SendError;\n\n#[derive(Error, Debug)]\npub enum Error {\n    #[error(transparent)]\n    CryptoErr(#[from] yaak_crypto::error::Error),\n\n    #[error(transparent)]\n    DbErr(#[from] yaak_models::error::Error),\n\n    #[error(transparent)]\n    TemplateErr(#[from] yaak_templates::error::Error),\n\n    #[error(\"IO error: {0}\")]\n    IoErr(#[from] io::Error),\n\n    #[error(\"Grpc send error: {0}\")]\n    GrpcSendErr(#[from] SendError<InternalEvent>),\n\n    #[error(\"Failed to send request: {0}\")]\n    RequestError(#[from] reqwest::Error),\n\n    #[error(\"JSON error: {0}\")]\n    JsonErr(#[from] serde_json::Error),\n\n    #[error(\"API Error: {0}\")]\n    ApiErr(String),\n\n    #[error(\"Timeout elapsed: {0}\")]\n    TimeoutElapsed(#[from] tokio::time::error::Elapsed),\n\n    #[error(\"Plugin not found: {0}\")]\n    PluginNotFoundErr(String),\n\n    #[error(\"Auth plugin not found: {0}\")]\n    AuthPluginNotFound(String),\n\n    #[error(\"Plugin error: {0}\")]\n    PluginErr(String),\n\n    #[error(\"zip error: {0}\")]\n    ZipError(#[from] zip_extract::ZipExtractError),\n\n    #[error(\"Client not initialized error\")]\n    ClientNotInitializedErr,\n\n    #[error(\"Unknown event received\")]\n    UnknownEventErr,\n}\n\nimpl Serialize for Error {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        serializer.serialize_str(self.to_string().as_ref())\n    }\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates/yaak-plugins/src/events.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse ts_rs::TS;\nuse yaak_models::models::{\n    AnyModel, Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest,\n    Workspace,\n};\nuse yaak_models::util::generate_prefixed_id;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct InternalEvent {\n    pub id: String,\n    pub plugin_ref_id: String,\n    pub plugin_name: String,\n    pub reply_id: Option<String>,\n    pub context: PluginContext,\n    pub payload: InternalEventPayload,\n}\n\n/// Special type used to deserialize everything but the payload. This is so we can\n/// catch any plugin-related type errors, since payload is sent by the plugin author\n/// and all other fields are sent by Yaak first-party code.\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub(crate) struct InternalEventRawPayload {\n    pub id: String,\n    pub plugin_ref_id: String,\n    pub plugin_name: String,\n    pub reply_id: Option<String>,\n    pub context: PluginContext,\n    pub payload: serde_json::Value,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct PluginContext {\n    pub id: String,\n    pub label: Option<String>,\n    pub workspace_id: Option<String>,\n}\n\nimpl PluginContext {\n    pub fn new_empty() -> Self {\n        Self { id: \"default\".to_string(), label: None, workspace_id: None }\n    }\n\n    pub fn new(label: Option<String>, workspace_id: Option<String>) -> Self {\n        Self { label, workspace_id, id: generate_prefixed_id(\"pctx\") }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\", tag = \"type\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub enum InternalEventPayload {\n    BootRequest(BootRequest),\n    BootResponse,\n\n    ReloadResponse(ReloadResponse),\n\n    TerminateRequest,\n    TerminateResponse,\n\n    ImportRequest(ImportRequest),\n    ImportResponse(ImportResponse),\n\n    FilterRequest(FilterRequest),\n    FilterResponse(FilterResponse),\n\n    ExportHttpRequestRequest(ExportHttpRequestRequest),\n    ExportHttpRequestResponse(ExportHttpRequestResponse),\n\n    SendHttpRequestRequest(SendHttpRequestRequest),\n    SendHttpRequestResponse(SendHttpRequestResponse),\n\n    ListCookieNamesRequest(ListCookieNamesRequest),\n    ListCookieNamesResponse(ListCookieNamesResponse),\n    GetCookieValueRequest(GetCookieValueRequest),\n    GetCookieValueResponse(GetCookieValueResponse),\n\n    // HTTP Request Actions\n    GetHttpRequestActionsRequest(EmptyPayload),\n    GetHttpRequestActionsResponse(GetHttpRequestActionsResponse),\n    CallHttpRequestActionRequest(CallHttpRequestActionRequest),\n\n    // WebSocket Request Actions\n    GetWebsocketRequestActionsRequest(EmptyPayload),\n    GetWebsocketRequestActionsResponse(GetWebsocketRequestActionsResponse),\n    CallWebsocketRequestActionRequest(CallWebsocketRequestActionRequest),\n\n    // Workspace Actions\n    GetWorkspaceActionsRequest(EmptyPayload),\n    GetWorkspaceActionsResponse(GetWorkspaceActionsResponse),\n    CallWorkspaceActionRequest(CallWorkspaceActionRequest),\n\n    // Folder Actions\n    GetFolderActionsRequest(EmptyPayload),\n    GetFolderActionsResponse(GetFolderActionsResponse),\n    CallFolderActionRequest(CallFolderActionRequest),\n\n    // Grpc Request Actions\n    GetGrpcRequestActionsRequest(EmptyPayload),\n    GetGrpcRequestActionsResponse(GetGrpcRequestActionsResponse),\n    CallGrpcRequestActionRequest(CallGrpcRequestActionRequest),\n\n    // Template Functions\n    GetTemplateFunctionSummaryRequest(EmptyPayload),\n    GetTemplateFunctionSummaryResponse(GetTemplateFunctionSummaryResponse),\n    GetTemplateFunctionConfigRequest(GetTemplateFunctionConfigRequest),\n    GetTemplateFunctionConfigResponse(GetTemplateFunctionConfigResponse),\n    CallTemplateFunctionRequest(CallTemplateFunctionRequest),\n    CallTemplateFunctionResponse(CallTemplateFunctionResponse),\n\n    // Http Authentication\n    GetHttpAuthenticationSummaryRequest(EmptyPayload),\n    GetHttpAuthenticationSummaryResponse(GetHttpAuthenticationSummaryResponse),\n    GetHttpAuthenticationConfigRequest(GetHttpAuthenticationConfigRequest),\n    GetHttpAuthenticationConfigResponse(GetHttpAuthenticationConfigResponse),\n    CallHttpAuthenticationRequest(CallHttpAuthenticationRequest),\n    CallHttpAuthenticationResponse(CallHttpAuthenticationResponse),\n    CallHttpAuthenticationActionRequest(CallHttpAuthenticationActionRequest),\n    CallHttpAuthenticationActionResponse(EmptyPayload),\n\n    CopyTextRequest(CopyTextRequest),\n    CopyTextResponse(EmptyPayload),\n\n    RenderHttpRequestRequest(RenderHttpRequestRequest),\n    RenderHttpRequestResponse(RenderHttpRequestResponse),\n\n    RenderGrpcRequestRequest(RenderGrpcRequestRequest),\n    RenderGrpcRequestResponse(RenderGrpcRequestResponse),\n\n    TemplateRenderRequest(TemplateRenderRequest),\n    TemplateRenderResponse(TemplateRenderResponse),\n\n    GetKeyValueRequest(GetKeyValueRequest),\n    GetKeyValueResponse(GetKeyValueResponse),\n    SetKeyValueRequest(SetKeyValueRequest),\n    SetKeyValueResponse(SetKeyValueResponse),\n    DeleteKeyValueRequest(DeleteKeyValueRequest),\n    DeleteKeyValueResponse(DeleteKeyValueResponse),\n\n    OpenWindowRequest(OpenWindowRequest),\n    WindowNavigateEvent(WindowNavigateEvent),\n    WindowCloseEvent,\n    CloseWindowRequest(CloseWindowRequest),\n\n    OpenExternalUrlRequest(OpenExternalUrlRequest),\n    OpenExternalUrlResponse(EmptyPayload),\n\n    ShowToastRequest(ShowToastRequest),\n    ShowToastResponse(EmptyPayload),\n\n    PromptTextRequest(PromptTextRequest),\n    PromptTextResponse(PromptTextResponse),\n\n    PromptFormRequest(PromptFormRequest),\n    PromptFormResponse(PromptFormResponse),\n\n    WindowInfoRequest(WindowInfoRequest),\n    WindowInfoResponse(WindowInfoResponse),\n\n    ListOpenWorkspacesRequest(ListOpenWorkspacesRequest),\n    ListOpenWorkspacesResponse(ListOpenWorkspacesResponse),\n\n    GetHttpRequestByIdRequest(GetHttpRequestByIdRequest),\n    GetHttpRequestByIdResponse(GetHttpRequestByIdResponse),\n\n    FindHttpResponsesRequest(FindHttpResponsesRequest),\n    FindHttpResponsesResponse(FindHttpResponsesResponse),\n    ListHttpRequestsRequest(ListHttpRequestsRequest),\n    ListHttpRequestsResponse(ListHttpRequestsResponse),\n    ListFoldersRequest(ListFoldersRequest),\n    ListFoldersResponse(ListFoldersResponse),\n\n    UpsertModelRequest(UpsertModelRequest),\n    UpsertModelResponse(UpsertModelResponse),\n\n    DeleteModelRequest(DeleteModelRequest),\n    DeleteModelResponse(DeleteModelResponse),\n\n    GetThemesRequest(GetThemesRequest),\n    GetThemesResponse(GetThemesResponse),\n\n    /// Returned when a plugin doesn't get run, just so the server\n    /// has something to listen for\n    EmptyResponse(EmptyPayload),\n\n    ErrorResponse(ErrorResponse),\n}\n\nimpl InternalEventPayload {\n    pub fn type_name(&self) -> String {\n        if let Ok(serde_json::Value::Object(map)) = serde_json::to_value(self) {\n            map.get(\"type\").map(|s| s.as_str().unwrap_or(\"unknown\").to_string())\n        } else {\n            None\n        }\n        .unwrap_or(\"invalid_event\".to_string())\n    }\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default)]\n#[ts(export, type = \"{}\", export_to = \"gen_events.ts\")]\npub struct EmptyPayload {}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default)]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ErrorResponse {\n    pub error: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct BootRequest {\n    pub dir: String,\n    pub watch: bool,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ReloadResponse {\n    pub silent: bool,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ImportRequest {\n    pub content: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ImportResponse {\n    pub resources: ImportResources,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FilterRequest {\n    pub content: String,\n    pub filter: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FilterResponse {\n    pub content: String,\n    #[ts(optional)]\n    pub error: Option<String>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ExportHttpRequestRequest {\n    pub http_request: HttpRequest,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ExportHttpRequestResponse {\n    pub content: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct SendHttpRequestRequest {\n    #[ts(type = \"Partial<HttpRequest>\")]\n    pub http_request: HttpRequest,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct SendHttpRequestResponse {\n    pub http_response: HttpResponse,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default)]\n#[ts(export, type = \"{}\", export_to = \"gen_events.ts\")]\npub struct ListCookieNamesRequest {}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ListCookieNamesResponse {\n    pub names: Vec<String>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetCookieValueRequest {\n    pub name: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetCookieValueResponse {\n    pub value: Option<String>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CopyTextRequest {\n    pub text: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct RenderHttpRequestRequest {\n    pub http_request: HttpRequest,\n    pub purpose: RenderPurpose,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct RenderHttpRequestResponse {\n    pub http_request: HttpRequest,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct RenderGrpcRequestRequest {\n    pub grpc_request: GrpcRequest,\n    pub purpose: RenderPurpose,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct RenderGrpcRequestResponse {\n    pub grpc_request: GrpcRequest,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetThemesRequest {}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ThemeComponents {\n    #[ts(optional)]\n    pub dialog: Option<ThemeComponentColors>,\n    #[ts(optional)]\n    pub menu: Option<ThemeComponentColors>,\n    #[ts(optional)]\n    pub toast: Option<ThemeComponentColors>,\n    #[ts(optional)]\n    pub sidebar: Option<ThemeComponentColors>,\n    #[ts(optional)]\n    pub response_pane: Option<ThemeComponentColors>,\n    #[ts(optional)]\n    pub app_header: Option<ThemeComponentColors>,\n    #[ts(optional)]\n    pub button: Option<ThemeComponentColors>,\n    #[ts(optional)]\n    pub banner: Option<ThemeComponentColors>,\n    #[ts(optional)]\n    pub template_tag: Option<ThemeComponentColors>,\n    #[ts(optional)]\n    pub url_bar: Option<ThemeComponentColors>,\n    #[ts(optional)]\n    pub editor: Option<ThemeComponentColors>,\n    #[ts(optional)]\n    pub input: Option<ThemeComponentColors>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ThemeComponentColors {\n    #[ts(optional)]\n    pub surface: Option<String>,\n    #[ts(optional)]\n    pub surface_highlight: Option<String>,\n    #[ts(optional)]\n    pub surface_active: Option<String>,\n\n    #[ts(optional)]\n    pub text: Option<String>,\n    #[ts(optional)]\n    pub text_subtle: Option<String>,\n    #[ts(optional)]\n    pub text_subtlest: Option<String>,\n\n    #[ts(optional)]\n    pub border: Option<String>,\n    #[ts(optional)]\n    pub border_subtle: Option<String>,\n    #[ts(optional)]\n    pub border_focus: Option<String>,\n\n    #[ts(optional)]\n    pub shadow: Option<String>,\n    #[ts(optional)]\n    pub backdrop: Option<String>,\n    #[ts(optional)]\n    pub selection: Option<String>,\n\n    #[ts(optional)]\n    pub primary: Option<String>,\n    #[ts(optional)]\n    pub secondary: Option<String>,\n    #[ts(optional)]\n    pub info: Option<String>,\n    #[ts(optional)]\n    pub success: Option<String>,\n    #[ts(optional)]\n    pub notice: Option<String>,\n    #[ts(optional)]\n    pub warning: Option<String>,\n    #[ts(optional)]\n    pub danger: Option<String>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct Theme {\n    /// How the theme is identified. This should never be changed\n    pub id: String,\n    /// The friendly name of the theme to be displayed to the user\n    pub label: String,\n    /// Whether the theme will be used for dark or light appearance\n    pub dark: bool,\n    /// The default top-level colors for the theme\n    pub base: ThemeComponentColors,\n    /// Optionally override theme for individual UI components for more control\n    #[ts(optional)]\n    pub components: Option<ThemeComponents>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetThemesResponse {\n    pub themes: Vec<Theme>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct TemplateRenderRequest {\n    pub data: serde_json::Value,\n    pub purpose: RenderPurpose,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct TemplateRenderResponse {\n    pub data: serde_json::Value,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct OpenWindowRequest {\n    pub url: String,\n    /// Label for the window. If not provided, a random one will be generated.\n    pub label: String,\n\n    #[ts(optional)]\n    pub title: Option<String>,\n\n    #[ts(optional)]\n    pub size: Option<WindowSize>,\n\n    #[ts(optional)]\n    pub data_dir_key: Option<String>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct OpenExternalUrlRequest {\n    pub url: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct WindowSize {\n    pub width: f64,\n    pub height: f64,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CloseWindowRequest {\n    pub label: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct WindowNavigateEvent {\n    pub url: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ShowToastRequest {\n    pub message: String,\n\n    #[ts(optional)]\n    pub color: Option<Color>,\n\n    #[ts(optional)]\n    pub icon: Option<Icon>,\n\n    #[ts(optional)]\n    pub timeout: Option<i32>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct PromptTextRequest {\n    // A unique ID to identify the prompt (eg. \"enter-password\")\n    pub id: String,\n    // Title to show on the prompt dialog\n    pub title: String,\n    // Text to show on the label above the input\n    pub label: String,\n    #[ts(optional)]\n    pub description: Option<String>,\n    #[ts(optional)]\n    pub default_value: Option<String>,\n    #[ts(optional)]\n    pub placeholder: Option<String>,\n    /// Text to add to the confirmation button\n    #[ts(optional)]\n    pub confirm_text: Option<String>,\n    #[ts(optional)]\n    pub password: Option<bool>,\n    /// Text to add to the cancel button\n    #[ts(optional)]\n    pub cancel_text: Option<String>,\n    /// Require the user to enter a non-empty value\n    #[ts(optional)]\n    pub required: Option<bool>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct PromptTextResponse {\n    pub value: Option<String>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct PromptFormRequest {\n    pub id: String,\n    pub title: String,\n    #[ts(optional)]\n    pub description: Option<String>,\n    pub inputs: Vec<FormInput>,\n    #[ts(optional)]\n    pub confirm_text: Option<String>,\n    #[ts(optional)]\n    pub cancel_text: Option<String>,\n    #[ts(optional)]\n    pub size: Option<DialogSize>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub enum DialogSize {\n    Sm,\n    Md,\n    Lg,\n    Full,\n    Dynamic,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct PromptFormResponse {\n    pub values: Option<HashMap<String, JsonPrimitive>>,\n    #[ts(optional)]\n    pub done: Option<bool>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct WindowInfoRequest {\n    pub label: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct WindowInfoResponse {\n    pub request_id: Option<String>,\n    pub environment_id: Option<String>,\n    pub workspace_id: Option<String>,\n    pub label: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ListOpenWorkspacesRequest {}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ListOpenWorkspacesResponse {\n    pub workspaces: Vec<WorkspaceInfo>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct WorkspaceInfo {\n    pub id: String,\n    pub name: String,\n    #[ts(skip)]\n    pub label: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub enum Color {\n    Primary,\n    Secondary,\n    Info,\n    Success,\n    Notice,\n    Warning,\n    Danger,\n}\n\nimpl Default for Color {\n    fn default() -> Self {\n        Color::Secondary\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub enum Icon {\n    AlertTriangle,\n    Check,\n    CheckCircle,\n    ChevronDown,\n    Copy,\n    Info,\n    Pin,\n    Search,\n    Trash,\n\n    #[serde(untagged)]\n    #[ts(type = \"\\\"_unknown\\\"\")]\n    _Unknown(String),\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetHttpAuthenticationSummaryResponse {\n    pub name: String,\n    pub label: String,\n    pub short_label: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct HttpAuthenticationAction {\n    pub label: String,\n\n    #[ts(optional)]\n    pub icon: Option<Icon>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetHttpAuthenticationConfigRequest {\n    pub context_id: String,\n    pub values: HashMap<String, JsonPrimitive>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetHttpAuthenticationConfigResponse {\n    pub args: Vec<FormInput>,\n    pub plugin_ref_id: String,\n\n    #[ts(optional)]\n    pub actions: Option<Vec<HttpAuthenticationAction>>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct HttpHeader {\n    pub name: String,\n    pub value: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallHttpAuthenticationRequest {\n    pub context_id: String,\n    pub values: HashMap<String, JsonPrimitive>,\n    pub method: String,\n    pub url: String,\n    pub headers: Vec<HttpHeader>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallHttpAuthenticationActionRequest {\n    pub index: i32,\n    pub plugin_ref_id: String,\n    pub args: CallHttpAuthenticationActionArgs,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallHttpAuthenticationActionArgs {\n    pub context_id: String,\n    pub values: HashMap<String, JsonPrimitive>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(untagged)]\n#[ts(export, export_to = \"gen_events.ts\")]\npub enum JsonPrimitive {\n    String(String),\n    Number(f64),\n    Boolean(bool),\n    Null,\n}\n\nimpl From<serde_json::Value> for JsonPrimitive {\n    fn from(value: serde_json::Value) -> Self {\n        match value {\n            serde_json::Value::Null => JsonPrimitive::Null,\n            serde_json::Value::Bool(b) => JsonPrimitive::Boolean(b),\n            serde_json::Value::Number(n) => JsonPrimitive::Number(n.as_f64().unwrap()),\n            serde_json::Value::String(s) => JsonPrimitive::String(s),\n            v => panic!(\"Unsupported JSON primitive type {:?}\", v),\n        }\n    }\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallHttpAuthenticationResponse {\n    /// HTTP headers to add to the request. Existing headers will be replaced, while\n    /// new headers will be added.\n    #[ts(optional)]\n    pub set_headers: Option<Vec<HttpHeader>>,\n\n    /// Query parameters to add to the request. Existing params will be replaced, while\n    /// new params will be added.\n    #[ts(optional)]\n    pub set_query_parameters: Option<Vec<HttpHeader>>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetTemplateFunctionSummaryResponse {\n    pub functions: Vec<TemplateFunction>,\n    pub plugin_ref_id: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetTemplateFunctionConfigRequest {\n    pub context_id: String,\n    pub name: String,\n    pub values: HashMap<String, JsonPrimitive>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetTemplateFunctionConfigResponse {\n    pub function: TemplateFunction,\n    pub plugin_ref_id: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub enum TemplateFunctionPreviewType {\n    Live,\n    Click,\n    None,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct TemplateFunction {\n    pub name: String,\n\n    #[ts(optional)]\n    pub preview_type: Option<TemplateFunctionPreviewType>,\n\n    #[ts(optional)]\n    pub description: Option<String>,\n\n    /// Also support alternative names. This is useful for not breaking existing\n    /// tags when changing the `name` property\n    #[ts(optional)]\n    pub aliases: Option<Vec<String>>,\n    pub args: Vec<TemplateFunctionArg>,\n\n    /// A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons).\n    #[ts(optional)]\n    pub preview_args: Option<Vec<String>>,\n}\n\n/// Similar to FormInput, but contains\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\", untagged)]\n#[ts(export, export_to = \"gen_events.ts\")]\npub enum TemplateFunctionArg {\n    FormInput(FormInput),\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\", tag = \"type\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub enum FormInput {\n    Text(FormInputText),\n    Editor(FormInputEditor),\n    Select(FormInputSelect),\n    Checkbox(FormInputCheckbox),\n    File(FormInputFile),\n    HttpRequest(FormInputHttpRequest),\n    Accordion(FormInputAccordion),\n    HStack(FormInputHStack),\n    Banner(FormInputBanner),\n    Markdown(FormInputMarkdown),\n    KeyValue(FormInputKeyValue),\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FormInputBase {\n    /// The name of the input. The value will be stored at this object attribute in the resulting data\n    pub name: String,\n\n    /// Whether this input is visible for the given configuration. Use this to\n    /// make branching forms.\n    #[ts(optional)]\n    pub hidden: Option<bool>,\n\n    /// Whether the user must fill in the argument\n    #[ts(optional)]\n    pub optional: Option<bool>,\n\n    /// The label of the input\n    #[ts(optional)]\n    pub label: Option<String>,\n\n    /// Visually hide the label of the input\n    #[ts(optional)]\n    pub hide_label: Option<bool>,\n\n    /// The default value\n    #[ts(optional)]\n    pub default_value: Option<String>,\n\n    #[ts(optional)]\n    pub disabled: Option<bool>,\n\n    /// Longer description of the input, likely shown in a tooltip\n    #[ts(optional)]\n    pub description: Option<String>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FormInputText {\n    #[serde(flatten)]\n    pub base: FormInputBase,\n\n    /// Placeholder for the text input\n    #[ts(optional = nullable)]\n    pub placeholder: Option<String>,\n\n    /// Placeholder for the text input\n    #[ts(optional)]\n    pub password: Option<bool>,\n\n    /// Whether to allow newlines in the input, like a <textarea/>\n    #[ts(optional)]\n    pub multi_line: Option<bool>,\n\n    #[ts(optional)]\n    pub completion_options: Option<Vec<GenericCompletionOption>>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub enum EditorLanguage {\n    Text,\n    Javascript,\n    Json,\n    Html,\n    Xml,\n    Graphql,\n    Markdown,\n    C,\n    Clojure,\n    Csharp,\n    Go,\n    Http,\n    Java,\n    Kotlin,\n    ObjectiveC,\n    Ocaml,\n    Php,\n    Powershell,\n    Python,\n    R,\n    Ruby,\n    Shell,\n    Swift,\n}\n\nimpl Default for EditorLanguage {\n    fn default() -> Self {\n        Self::Text\n    }\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FormInputEditor {\n    #[serde(flatten)]\n    pub base: FormInputBase,\n\n    /// Placeholder for the text input\n    #[ts(optional = nullable)]\n    pub placeholder: Option<String>,\n\n    /// Don't show the editor gutter (line numbers, folds, etc.)\n    #[ts(optional)]\n    pub hide_gutter: Option<bool>,\n\n    /// Language for syntax highlighting\n    #[ts(optional)]\n    pub language: Option<EditorLanguage>,\n\n    #[ts(optional)]\n    pub read_only: Option<bool>,\n\n    /// Fixed number of visible rows\n    #[ts(optional)]\n    pub rows: Option<i32>,\n\n    #[ts(optional)]\n    pub completion_options: Option<Vec<GenericCompletionOption>>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GenericCompletionOption {\n    label: String,\n\n    #[ts(optional)]\n    detail: Option<String>,\n\n    #[ts(optional)]\n    info: Option<String>,\n\n    #[ts(optional)]\n    #[serde(rename = \"type\")]\n    pub type_: Option<CompletionOptionType>,\n\n    #[ts(optional)]\n    pub boost: Option<i32>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub enum CompletionOptionType {\n    Constant,\n    Variable,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FormInputHttpRequest {\n    #[serde(flatten)]\n    pub base: FormInputBase,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FormInputFile {\n    #[serde(flatten)]\n    pub base: FormInputBase,\n\n    /// The title of the file selection window\n    pub title: String,\n\n    /// Allow selecting multiple files\n    #[ts(optional)]\n    pub multiple: Option<bool>,\n\n    // Select a directory, not a file\n    #[ts(optional)]\n    pub directory: Option<bool>,\n\n    // Default file path for the selection dialog\n    #[ts(optional)]\n    pub default_path: Option<String>,\n\n    // Specify to only allow selection of certain file extensions\n    #[ts(optional)]\n    pub filters: Option<Vec<FileFilter>>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FileFilter {\n    pub name: String,\n    /// File extensions to require\n    pub extensions: Vec<String>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FormInputSelect {\n    #[serde(flatten)]\n    pub base: FormInputBase,\n\n    /// The options that will be available in the select input\n    pub options: Vec<FormInputSelectOption>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FormInputCheckbox {\n    #[serde(flatten)]\n    pub base: FormInputBase,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FormInputSelectOption {\n    pub label: String,\n    pub value: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FormInputAccordion {\n    pub label: String,\n\n    #[ts(optional)]\n    pub inputs: Option<Vec<FormInput>>,\n\n    #[ts(optional)]\n    pub hidden: Option<bool>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FormInputHStack {\n    #[ts(optional)]\n    pub inputs: Option<Vec<FormInput>>,\n\n    #[ts(optional)]\n    pub hidden: Option<bool>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FormInputBanner {\n    #[ts(optional)]\n    pub inputs: Option<Vec<FormInput>>,\n\n    #[ts(optional)]\n    pub hidden: Option<bool>,\n\n    #[ts(optional)]\n    pub color: Option<Color>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FormInputMarkdown {\n    pub content: String,\n\n    #[ts(optional)]\n    pub hidden: Option<bool>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FormInputKeyValue {\n    #[serde(flatten)]\n    pub base: FormInputBase,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\", tag = \"type\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub enum Content {\n    Text { content: String },\n    Markdown { content: String },\n}\n\nimpl Default for Content {\n    fn default() -> Self {\n        Self::Text { content: String::default() }\n    }\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallTemplateFunctionRequest {\n    pub name: String,\n    pub args: CallTemplateFunctionArgs,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallTemplateFunctionResponse {\n    pub value: Option<String>,\n    #[ts(optional)]\n    pub error: Option<String>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallTemplateFunctionArgs {\n    pub purpose: RenderPurpose,\n    pub values: HashMap<String, JsonPrimitive>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub enum RenderPurpose {\n    Send,\n    Preview,\n}\n\nimpl Default for RenderPurpose {\n    fn default() -> Self {\n        RenderPurpose::Preview\n    }\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetHttpRequestActionsResponse {\n    pub actions: Vec<HttpRequestAction>,\n    pub plugin_ref_id: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct HttpRequestAction {\n    pub label: String,\n    #[ts(optional)]\n    pub icon: Option<Icon>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallHttpRequestActionRequest {\n    pub index: i32,\n    pub plugin_ref_id: String,\n    pub args: CallHttpRequestActionArgs,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallHttpRequestActionArgs {\n    pub http_request: HttpRequest,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetWebsocketRequestActionsResponse {\n    pub actions: Vec<WebsocketRequestAction>,\n    pub plugin_ref_id: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct WebsocketRequestAction {\n    pub label: String,\n    #[ts(optional)]\n    pub icon: Option<Icon>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallWebsocketRequestActionRequest {\n    pub index: i32,\n    pub plugin_ref_id: String,\n    pub args: CallWebsocketRequestActionArgs,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallWebsocketRequestActionArgs {\n    pub websocket_request: WebsocketRequest,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetWorkspaceActionsResponse {\n    pub actions: Vec<WorkspaceAction>,\n    pub plugin_ref_id: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct WorkspaceAction {\n    pub label: String,\n    #[ts(optional)]\n    pub icon: Option<Icon>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallWorkspaceActionRequest {\n    pub index: i32,\n    pub plugin_ref_id: String,\n    pub args: CallWorkspaceActionArgs,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallWorkspaceActionArgs {\n    pub workspace: Workspace,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetFolderActionsResponse {\n    pub actions: Vec<FolderAction>,\n    pub plugin_ref_id: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FolderAction {\n    pub label: String,\n    #[ts(optional)]\n    pub icon: Option<Icon>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallFolderActionRequest {\n    pub index: i32,\n    pub plugin_ref_id: String,\n    pub args: CallFolderActionArgs,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallFolderActionArgs {\n    pub folder: Folder,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetGrpcRequestActionsResponse {\n    pub actions: Vec<GrpcRequestAction>,\n    pub plugin_ref_id: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GrpcRequestAction {\n    pub label: String,\n    #[ts(optional)]\n    pub icon: Option<Icon>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallGrpcRequestActionRequest {\n    pub index: i32,\n    pub plugin_ref_id: String,\n    pub args: CallGrpcRequestActionArgs,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct CallGrpcRequestActionArgs {\n    pub grpc_request: GrpcRequest,\n    pub proto_files: Vec<String>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetHttpRequestByIdRequest {\n    pub id: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetHttpRequestByIdResponse {\n    pub http_request: Option<HttpRequest>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FindHttpResponsesRequest {\n    pub request_id: String,\n    #[ts(optional)]\n    pub limit: Option<i32>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct FindHttpResponsesResponse {\n    pub http_responses: Vec<HttpResponse>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ListHttpRequestsRequest {\n    #[ts(optional)]\n    pub folder_id: Option<String>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ListHttpRequestsResponse {\n    pub http_requests: Vec<HttpRequest>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default)]\n#[ts(export, type = \"{}\", export_to = \"gen_events.ts\")]\npub struct ListFoldersRequest {}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ListFoldersResponse {\n    pub folders: Vec<Folder>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct UpsertModelRequest {\n    pub model: AnyModel,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct UpsertModelResponse {\n    pub model: AnyModel,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct DeleteModelRequest {\n    pub model: String,\n    pub id: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct DeleteModelResponse {\n    pub model: AnyModel,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct ImportResources {\n    pub workspaces: Vec<Workspace>,\n    pub environments: Vec<Environment>,\n    pub folders: Vec<Folder>,\n    pub http_requests: Vec<HttpRequest>,\n    pub grpc_requests: Vec<GrpcRequest>,\n    pub websocket_requests: Vec<WebsocketRequest>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetKeyValueRequest {\n    pub key: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct GetKeyValueResponse {\n    #[ts(optional)]\n    pub value: Option<String>,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct SetKeyValueRequest {\n    pub key: String,\n    pub value: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default)]\n#[ts(export, type = \"{}\", export_to = \"gen_events.ts\")]\npub struct SetKeyValueResponse {}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct DeleteKeyValueRequest {\n    pub key: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]\n#[serde(default)]\n#[ts(export, export_to = \"gen_events.ts\")]\npub struct DeleteKeyValueResponse {\n    pub deleted: bool,\n}\n"
  },
  {
    "path": "crates/yaak-plugins/src/install.rs",
    "content": "use crate::api::{PluginVersion, download_plugin_archive, get_plugin};\nuse crate::checksum::compute_checksum;\nuse crate::error::Error::PluginErr;\nuse crate::error::Result;\nuse crate::events::PluginContext;\nuse crate::manager::PluginManager;\nuse chrono::Utc;\nuse log::info;\nuse std::fs::{create_dir_all, remove_dir_all};\nuse std::io::Cursor;\nuse std::sync::Arc;\nuse yaak_models::models::{Plugin, PluginSource};\nuse yaak_models::query_manager::QueryManager;\nuse yaak_models::util::UpdateSource;\n\n/// Delete a plugin from the database and uninstall it.\npub async fn delete_and_uninstall(\n    plugin_manager: Arc<PluginManager>,\n    query_manager: &QueryManager,\n    plugin_context: &PluginContext,\n    plugin_id: &str,\n) -> Result<Plugin> {\n    let update_source = match plugin_context.label.clone() {\n        Some(label) => UpdateSource::from_window_label(label),\n        None => UpdateSource::Background,\n    };\n    // Scope the db connection so it doesn't live across await\n    let plugin = {\n        let db = query_manager.connect();\n        db.delete_plugin_by_id(plugin_id, &update_source)?\n    };\n    plugin_manager.uninstall(plugin_context, plugin.directory.as_str()).await?;\n    Ok(plugin)\n}\n\n/// Download and install a plugin.\npub async fn download_and_install(\n    plugin_manager: Arc<PluginManager>,\n    query_manager: &QueryManager,\n    http_client: &reqwest::Client,\n    plugin_context: &PluginContext,\n    name: &str,\n    version: Option<String>,\n) -> Result<PluginVersion> {\n    info!(\"Installing plugin {} {}\", name, version.clone().unwrap_or_default());\n    let plugin_version = get_plugin(http_client, name, version).await?;\n    let resp = download_plugin_archive(http_client, &plugin_version).await?;\n    let bytes = resp.bytes().await?;\n\n    let checksum = compute_checksum(&bytes);\n    if checksum != plugin_version.checksum {\n        return Err(PluginErr(format!(\n            \"Checksum mismatch {}b {checksum} != {}\",\n            bytes.len(),\n            plugin_version.checksum\n        )));\n    }\n\n    info!(\"Checksum matched {}\", checksum);\n\n    let plugin_dir = plugin_manager.installed_plugin_dir.join(name);\n    let plugin_dir_str = plugin_dir.to_str().unwrap().to_string();\n\n    // Re-create the plugin directory\n    let _ = remove_dir_all(&plugin_dir);\n    create_dir_all(&plugin_dir)?;\n\n    zip_extract::extract(Cursor::new(&bytes), &plugin_dir, true)?;\n    info!(\"Extracted plugin {} to {}\", plugin_version.id, plugin_dir_str);\n\n    // Scope the db connection so it doesn't live across await\n    let plugin = {\n        let db = query_manager.connect();\n        db.upsert_plugin(\n            &Plugin {\n                id: plugin_version.id.clone(),\n                checked_at: Some(Utc::now().naive_utc()),\n                directory: plugin_dir_str.clone(),\n                enabled: true,\n                url: Some(plugin_version.url.clone()),\n                source: PluginSource::Registry,\n                ..Default::default()\n            },\n            &UpdateSource::Background,\n        )?\n    };\n\n    plugin_manager.add_plugin(plugin_context, &plugin).await?;\n\n    info!(\"Installed plugin {} to {}\", plugin_version.id, plugin_dir_str);\n\n    Ok(plugin_version)\n}\n"
  },
  {
    "path": "crates/yaak-plugins/src/lib.rs",
    "content": "//! Core plugin system for Yaak.\n//!\n//! This crate provides the plugin manager and supporting functionality\n//! for running JavaScript plugins via a Node.js runtime.\n//!\n//! Note: This crate is Tauri-independent. Tauri integration is provided\n//! by yaak-app's plugins_ext module.\n\npub mod api;\nmod checksum;\npub mod error;\npub mod events;\npub mod install;\npub mod manager;\npub mod native_template_functions;\nmod nodejs;\npub mod plugin_handle;\npub mod plugin_meta;\nmod server_ws;\npub mod template_callback;\nmod util;\n"
  },
  {
    "path": "crates/yaak-plugins/src/manager.rs",
    "content": "use crate::error::Error::{\n    self, AuthPluginNotFound, ClientNotInitializedErr, PluginErr, PluginNotFoundErr,\n    UnknownEventErr,\n};\nuse crate::error::Result;\nuse crate::events::{\n    BootRequest, CallFolderActionRequest, CallGrpcRequestActionRequest,\n    CallHttpAuthenticationActionArgs, CallHttpAuthenticationActionRequest,\n    CallHttpAuthenticationRequest, CallHttpAuthenticationResponse, CallHttpRequestActionRequest,\n    CallTemplateFunctionArgs, CallTemplateFunctionRequest, CallTemplateFunctionResponse,\n    CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, EmptyPayload, ErrorResponse,\n    FilterRequest, FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse,\n    GetHttpAuthenticationConfigRequest, GetHttpAuthenticationConfigResponse,\n    GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,\n    GetTemplateFunctionConfigRequest, GetTemplateFunctionConfigResponse,\n    GetTemplateFunctionSummaryResponse, GetThemesRequest, GetThemesResponse,\n    GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportRequest, ImportResponse,\n    InternalEvent, InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose,\n    ShowToastRequest,\n};\nuse crate::native_template_functions::{template_function_keyring, template_function_secure};\nuse crate::nodejs::start_nodejs_plugin_runtime;\nuse crate::plugin_handle::PluginHandle;\nuse crate::plugin_meta::get_plugin_meta;\nuse crate::server_ws::PluginRuntimeServerWebsocket;\nuse log::{error, info, warn};\nuse std::collections::{HashMap, HashSet};\nuse std::env;\nuse std::path::{Path, PathBuf};\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::fs::read_dir;\nuse tokio::net::TcpListener;\nuse tokio::sync::mpsc::error::TrySendError;\nuse tokio::sync::{Mutex, mpsc, oneshot};\nuse tokio::time::{Instant, timeout};\nuse yaak_models::models::{Plugin, PluginSource};\nuse yaak_models::query_manager::QueryManager;\nuse yaak_models::util::{UpdateSource, generate_id};\nuse yaak_templates::error::Error::RenderError;\nuse yaak_templates::error::Result as TemplateResult;\n\n#[derive(Clone)]\npub struct PluginManager {\n    subscribers: Arc<Mutex<HashMap<String, mpsc::Sender<InternalEvent>>>>,\n    plugin_handles: Arc<Mutex<Vec<PluginHandle>>>,\n    kill_tx: tokio::sync::watch::Sender<bool>,\n    killed_rx: Arc<Mutex<Option<oneshot::Receiver<()>>>>,\n    ws_service: Arc<PluginRuntimeServerWebsocket>,\n    vendored_plugin_dir: PathBuf,\n    pub(crate) installed_plugin_dir: PathBuf,\n    dev_mode: bool,\n    /// Errors from plugin initialization, retrievable once via `take_init_errors`.\n    init_errors: Arc<Mutex<Vec<(String, String)>>>,\n}\n\n/// Callback for plugin initialization events (e.g., toast notifications)\npub type PluginInitCallback = Box<dyn Fn(ShowToastRequest) + Send + Sync>;\n\nimpl PluginManager {\n    /// Create a new PluginManager with the given paths.\n    ///\n    /// # Arguments\n    /// * `vendored_plugin_dir` - Path to vendored plugins directory\n    /// * `installed_plugin_dir` - Path to installed plugins directory\n    /// * `node_bin_path` - Path to the yaaknode binary\n    /// * `plugin_runtime_main` - Path to the plugin runtime index.cjs\n    /// * `query_manager` - Query manager for bundled plugin registration and loading\n    /// * `plugin_context` - Context to use while initializing plugins\n    /// * `dev_mode` - Whether the app is in dev mode (affects plugin loading)\n    pub async fn new(\n        vendored_plugin_dir: PathBuf,\n        installed_plugin_dir: PathBuf,\n        node_bin_path: PathBuf,\n        plugin_runtime_main: PathBuf,\n        query_manager: &QueryManager,\n        plugin_context: &PluginContext,\n        dev_mode: bool,\n    ) -> Result<PluginManager> {\n        let (events_tx, mut events_rx) = mpsc::channel(2048);\n        let (kill_server_tx, kill_server_rx) = tokio::sync::watch::channel(false);\n        let (killed_tx, killed_rx) = oneshot::channel();\n\n        let (client_disconnect_tx, mut client_disconnect_rx) = mpsc::channel(128);\n        let (client_connect_tx, mut client_connect_rx) = tokio::sync::watch::channel(false);\n        let ws_service =\n            PluginRuntimeServerWebsocket::new(events_tx, client_disconnect_tx, client_connect_tx);\n\n        let plugin_manager = PluginManager {\n            plugin_handles: Default::default(),\n            subscribers: Default::default(),\n            ws_service: Arc::new(ws_service.clone()),\n            kill_tx: kill_server_tx,\n            killed_rx: Arc::new(Mutex::new(Some(killed_rx))),\n            vendored_plugin_dir,\n            installed_plugin_dir,\n            dev_mode,\n            init_errors: Default::default(),\n        };\n\n        // Forward events to subscribers\n        let subscribers = plugin_manager.subscribers.clone();\n        tokio::spawn(async move {\n            while let Some(event) = events_rx.recv().await {\n                for (tx_id, tx) in subscribers.lock().await.iter_mut() {\n                    if let Err(e) = tx.try_send(event.clone()) {\n                        match e {\n                            TrySendError::Full(e) => {\n                                error!(\"Failed to send event to full subscriber {tx_id} {e:?}\");\n                            }\n                            TrySendError::Closed(_) => {\n                                // Subscriber already unsubscribed\n                            }\n                        }\n                    }\n                }\n            }\n        });\n\n        // Handle when client plugin runtime disconnects\n        tokio::spawn(async move {\n            while (client_disconnect_rx.recv().await).is_some() {\n                // Happens when the app is closed\n                info!(\"Plugin runtime client disconnected\");\n            }\n        });\n\n        let listen_addr = match option_env!(\"YAAK_PLUGIN_SERVER_PORT\") {\n            Some(port) => format!(\"127.0.0.1:{port}\"),\n            None => \"127.0.0.1:0\".to_string(),\n        };\n        let listener = TcpListener::bind(listen_addr).await.expect(\"Failed to bind TCP listener\");\n        let addr = listener.local_addr().expect(\"Failed to get local address\");\n\n        // 1. Wait for Node.js runtime to connect\n        let init_plugins_task = tokio::spawn(async move {\n            match client_connect_rx.changed().await {\n                Ok(_) => {\n                    info!(\"Plugin runtime client connected!\");\n                    // Note: initialize_all_plugins is now called separately by the app\n                    // after setting up the plugin list\n                }\n                Err(e) => {\n                    warn!(\"Failed to receive from client connection rx {e:?}\");\n                }\n            }\n        });\n\n        // 1. Spawn server in the background\n        info!(\"Starting plugin server on {addr}\");\n        tokio::spawn(async move {\n            ws_service.listen(listener).await;\n        });\n\n        // 2. Start Node.js runtime\n        start_nodejs_plugin_runtime(\n            &node_bin_path,\n            &plugin_runtime_main,\n            addr,\n            &kill_server_rx,\n            killed_tx,\n        )\n        .await?;\n        info!(\"Waiting for plugins to initialize\");\n        init_plugins_task.await.map_err(|e| PluginErr(e.to_string()))?;\n\n        let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;\n        let db = query_manager.connect();\n        for dir in &bundled_dirs {\n            if db.get_plugin_by_directory(dir).is_none() {\n                db.upsert_plugin(\n                    &Plugin {\n                        directory: dir.clone(),\n                        enabled: true,\n                        url: None,\n                        source: PluginSource::Bundled,\n                        ..Default::default()\n                    },\n                    &UpdateSource::Background,\n                )?;\n            }\n        }\n\n        let plugins = db.list_plugins()?;\n        drop(db);\n\n        let init_errors = plugin_manager.initialize_all_plugins(plugins, plugin_context).await;\n        if !init_errors.is_empty() {\n            for (dir, err) in &init_errors {\n                warn!(\"Plugin failed to initialize: {dir}: {err}\");\n            }\n            *plugin_manager.init_errors.lock().await = init_errors;\n        }\n\n        Ok(plugin_manager)\n    }\n\n    /// Take any initialization errors, clearing them from the manager.\n    /// Returns a list of `(plugin_directory, error_message)` pairs.\n    pub async fn take_init_errors(&self) -> Vec<(String, String)> {\n        std::mem::take(&mut *self.init_errors.lock().await)\n    }\n\n    /// Get the vendored plugin directory path (resolves dev mode path if applicable)\n    pub fn get_plugins_dir(&self) -> PathBuf {\n        if self.dev_mode {\n            // Use plugins directly for easy development\n            // Tauri runs from crates-tauri/yaak-app/, so go up two levels to reach project root\n            env::current_dir()\n                .map(|cwd| cwd.join(\"../../plugins\").canonicalize().unwrap())\n                .unwrap_or_else(|_| self.vendored_plugin_dir.clone())\n        } else {\n            self.vendored_plugin_dir.clone()\n        }\n    }\n\n    /// Read plugin directories from disk and return their paths.\n    /// This is useful for discovering bundled plugins.\n    pub async fn list_bundled_plugin_dirs(&self) -> Result<Vec<String>> {\n        let plugins_dir = self.get_plugins_dir();\n        info!(\"Loading bundled plugins from {plugins_dir:?}\");\n        read_plugins_dir(&plugins_dir).await\n    }\n\n    pub async fn resolve_plugins_for_runtime_from_db(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {\n        let bundled_dirs = match self.list_bundled_plugin_dirs().await {\n            Ok(dirs) => dirs,\n            Err(err) => {\n                warn!(\"Failed to read bundled plugin dirs for resolution: {err:?}\");\n                Vec::new()\n            }\n        };\n        self.resolve_plugins_for_runtime(plugins, bundled_dirs)\n    }\n\n    /// Resolve the plugin set for the current runtime instance.\n    ///\n    /// Rules:\n    /// - Drop bundled rows that are not present in this instance's bundled directory list.\n    /// - Deduplicate by plugin metadata name (fallback to directory key when metadata is unreadable).\n    /// - Prefer sources in this order: filesystem > registry > bundled.\n    /// - For same-source conflicts, prefer the most recently installed row (`created_at`).\n    fn resolve_plugins_for_runtime(\n        &self,\n        plugins: Vec<Plugin>,\n        bundled_dirs: Vec<String>,\n    ) -> Vec<Plugin> {\n        let bundled_dir_set: HashSet<String> = bundled_dirs.into_iter().collect();\n        let mut selected: HashMap<String, Plugin> = HashMap::new();\n\n        for plugin in plugins {\n            if matches!(plugin.source, PluginSource::Bundled)\n                && !bundled_dir_set.contains(&plugin.directory)\n            {\n                continue;\n            }\n\n            let key = match get_plugin_meta(Path::new(&plugin.directory)) {\n                Ok(meta) => meta.name,\n                Err(_) => format!(\"__dir__{}\", plugin.directory),\n            };\n\n            match selected.get(&key) {\n                Some(existing) if !prefer_plugin(&plugin, existing) => {}\n                _ => {\n                    selected.insert(key, plugin);\n                }\n            }\n        }\n\n        let mut resolved = selected.into_values().collect::<Vec<_>>();\n        resolved.sort_by(|a, b| b.created_at.cmp(&a.created_at));\n        resolved\n    }\n\n    pub async fn uninstall(&self, plugin_context: &PluginContext, dir: &str) -> Result<()> {\n        let plugin = self.get_plugin_by_dir(dir).await.ok_or(PluginNotFoundErr(dir.to_string()))?;\n        self.remove_plugin(plugin_context, &plugin).await\n    }\n\n    async fn remove_plugin(\n        &self,\n        plugin_context: &PluginContext,\n        plugin: &PluginHandle,\n    ) -> Result<()> {\n        // Terminate the plugin if it's enabled\n        if plugin.enabled {\n            self.send_to_plugin_and_wait(\n                plugin_context,\n                plugin,\n                &InternalEventPayload::TerminateRequest,\n                Duration::from_secs(5),\n            )\n            .await?;\n        }\n\n        // Remove the plugin from the list\n        let mut plugins = self.plugin_handles.lock().await;\n        let pos = plugins.iter().position(|p| p.ref_id == plugin.ref_id);\n        if let Some(pos) = pos {\n            plugins.remove(pos);\n        }\n\n        Ok(())\n    }\n\n    pub async fn add_plugin(&self, plugin_context: &PluginContext, plugin: &Plugin) -> Result<()> {\n        info!(\"Adding plugin by dir {}\", plugin.directory);\n\n        let maybe_tx = self.ws_service.app_to_plugin_events_tx.lock().await;\n        let tx = match &*maybe_tx {\n            None => return Err(ClientNotInitializedErr),\n            Some(tx) => tx,\n        };\n        let plugin_handle = PluginHandle::new(&plugin.directory, plugin.enabled, tx.clone())?;\n        let dir_path = Path::new(&plugin.directory);\n        let is_vendored = dir_path.starts_with(self.vendored_plugin_dir.as_path());\n        let is_installed = dir_path.starts_with(self.installed_plugin_dir.as_path());\n\n        // Boot the plugin if it's enabled\n        if plugin.enabled {\n            let event = self\n                .send_to_plugin_and_wait(\n                    plugin_context,\n                    &plugin_handle,\n                    &InternalEventPayload::BootRequest(BootRequest {\n                        dir: plugin.directory.clone(),\n                        watch: !is_vendored && !is_installed,\n                    }),\n                    Duration::from_secs(5),\n                )\n                .await?;\n\n            if !matches!(event.payload, InternalEventPayload::BootResponse) {\n                // Add it to the plugin handles anyway...\n                let mut plugin_handles = self.plugin_handles.lock().await;\n                plugin_handles.retain(|p| p.dir != plugin.directory);\n                plugin_handles.push(plugin_handle.clone());\n                return Err(UnknownEventErr);\n            }\n        }\n\n        let mut plugin_handles = self.plugin_handles.lock().await;\n        plugin_handles.retain(|p| p.dir != plugin.directory);\n        plugin_handles.push(plugin_handle.clone());\n\n        Ok(())\n    }\n\n    /// Initialize all plugins from the provided DB list.\n    /// Plugin candidates are resolved for this runtime instance before initialization.\n    /// Returns a list of (plugin_directory, error_message) for any plugins that failed to initialize.\n    pub async fn initialize_all_plugins(\n        &self,\n        plugins: Vec<Plugin>,\n        plugin_context: &PluginContext,\n    ) -> Vec<(String, String)> {\n        info!(\"Initializing all plugins\");\n        let start = Instant::now();\n        let mut errors = Vec::new();\n        let plugins = self.resolve_plugins_for_runtime_from_db(plugins).await;\n\n        // Rebuild runtime handles from scratch to avoid stale/duplicate handles.\n        let existing_handles = { self.plugin_handles.lock().await.clone() };\n        for plugin_handle in existing_handles {\n            if let Err(e) = self.remove_plugin(plugin_context, &plugin_handle).await {\n                error!(\"Failed to remove plugin {} {e:?}\", plugin_handle.dir);\n                errors.push((plugin_handle.dir.clone(), e.to_string()));\n            }\n        }\n\n        for plugin in plugins {\n            if let Err(e) = self.add_plugin(plugin_context, &plugin).await {\n                warn!(\"Failed to add plugin {} {e:?}\", plugin.directory);\n                errors.push((plugin.directory.clone(), e.to_string()));\n            }\n        }\n\n        let plugin_handles = self.plugin_handles.lock().await;\n        let names = plugin_handles.iter().map(|p| p.dir.to_string()).collect::<Vec<String>>();\n        info!(\n            \"Initialized {} plugins in {:?}:\\n  - {}\",\n            plugin_handles.len(),\n            start.elapsed(),\n            names.join(\"\\n  - \"),\n        );\n\n        errors\n    }\n\n    pub async fn subscribe(&self, label: &str) -> (String, mpsc::Receiver<InternalEvent>) {\n        let (tx, rx) = mpsc::channel(2048);\n        let rx_id = format!(\"{label}_{}\", generate_id());\n        self.subscribers.lock().await.insert(rx_id.clone(), tx);\n        (rx_id, rx)\n    }\n\n    pub async fn unsubscribe(&self, rx_id: &str) {\n        self.subscribers.lock().await.remove(rx_id);\n    }\n\n    pub async fn terminate(&self) {\n        self.kill_tx.send_replace(true);\n\n        // Wait for the plugin runtime process to actually exit\n        let killed_rx = self.killed_rx.lock().await.take();\n        if let Some(rx) = killed_rx {\n            if timeout(Duration::from_secs(5), rx).await.is_err() {\n                warn!(\"Timed out waiting for plugin runtime to exit\");\n            } else {\n                info!(\"Plugin runtime exited\")\n            }\n        }\n    }\n\n    pub async fn reply(\n        &self,\n        source_event: &InternalEvent,\n        payload: &InternalEventPayload,\n    ) -> Result<()> {\n        let plugin_context = source_event.to_owned().context;\n        let reply_id = Some(source_event.to_owned().id);\n        let plugin = self\n            .get_plugin_by_ref_id(source_event.plugin_ref_id.as_str())\n            .await\n            .ok_or(PluginNotFoundErr(source_event.plugin_ref_id.to_string()))?;\n        let event = plugin.build_event_to_send_raw(&plugin_context, &payload, reply_id);\n        plugin.send(&event).await\n    }\n\n    pub async fn get_plugin_by_ref_id(&self, ref_id: &str) -> Option<PluginHandle> {\n        self.plugin_handles.lock().await.iter().find(|p| p.ref_id == ref_id).cloned()\n    }\n\n    pub async fn get_plugin_by_dir(&self, dir: &str) -> Option<PluginHandle> {\n        self.plugin_handles.lock().await.iter().find(|p| p.dir == dir).cloned()\n    }\n\n    pub async fn get_plugin_by_name(&self, name: &str) -> Option<PluginHandle> {\n        for plugin in self.plugin_handles.lock().await.iter().cloned() {\n            let info = plugin.info();\n            if info.name == name {\n                return Some(plugin);\n            }\n        }\n        None\n    }\n\n    async fn send_to_plugin_and_wait(\n        &self,\n        plugin_context: &PluginContext,\n        plugin: &PluginHandle,\n        payload: &InternalEventPayload,\n        timeout_duration: Duration,\n    ) -> Result<InternalEvent> {\n        if !plugin.enabled {\n            return Err(Error::PluginErr(format!(\"Plugin {} is disabled\", plugin.metadata.name)));\n        }\n\n        let events = self\n            .send_to_plugins_and_wait(\n                plugin_context,\n                payload,\n                vec![plugin.to_owned()],\n                timeout_duration,\n            )\n            .await?;\n        Ok(events\n            .first()\n            .ok_or(Error::PluginErr(format!(\n                \"No plugin events returned for: {}\",\n                plugin.metadata.name\n            )))?\n            .to_owned())\n    }\n\n    async fn send_and_wait(\n        &self,\n        plugin_context: &PluginContext,\n        payload: &InternalEventPayload,\n        timeout_duration: Duration,\n    ) -> Result<Vec<InternalEvent>> {\n        let plugins = { self.plugin_handles.lock().await.clone() };\n        self.send_to_plugins_and_wait(plugin_context, payload, plugins, timeout_duration).await\n    }\n\n    async fn send_to_plugins_and_wait(\n        &self,\n        plugin_context: &PluginContext,\n        payload: &InternalEventPayload,\n        plugins: Vec<PluginHandle>,\n        timeout_duration: Duration,\n    ) -> Result<Vec<InternalEvent>> {\n        let event_type = payload.type_name();\n        let label = format!(\"wait[{}.{}]\", plugins.len(), event_type);\n        let (rx_id, mut rx) = self.subscribe(label.as_str()).await;\n\n        // 1. Build the events with IDs and everything\n        let events_to_send = plugins\n            .iter()\n            .filter(|p| p.enabled)\n            .map(|p| p.build_event_to_send(plugin_context, payload, None))\n            .collect::<Vec<InternalEvent>>();\n\n        // 2. Spawn thread to subscribe to incoming events and check reply ids\n        let sub_events_fut = {\n            let events_to_send = events_to_send.clone();\n\n            tokio::spawn(async move {\n                let mut found_events = Vec::new();\n\n                let collect_events = async {\n                    while let Some(event) = rx.recv().await {\n                        let matched_sent_event =\n                            events_to_send.iter().any(|e| Some(e.id.to_owned()) == event.reply_id);\n                        if matched_sent_event {\n                            found_events.push(event.clone());\n                        };\n\n                        let found_them_all = found_events.len() == events_to_send.len();\n                        if found_them_all {\n                            break;\n                        }\n                    }\n                };\n\n                // Timeout to prevent hanging forever if plugin doesn't respond\n                if timeout(timeout_duration, collect_events).await.is_err() {\n                    let responded_ids: Vec<&String> =\n                        found_events.iter().filter_map(|e| e.reply_id.as_ref()).collect();\n                    let non_responding: Vec<&str> = events_to_send\n                        .iter()\n                        .filter(|e| !responded_ids.contains(&&e.id))\n                        .map(|e| e.plugin_name.as_str())\n                        .collect();\n                    warn!(\n                        \"Timeout ({:?}) waiting for {} responses. Got {}/{} responses. \\\n                        Non-responding plugins: [{}]\",\n                        timeout_duration,\n                        event_type,\n                        found_events.len(),\n                        events_to_send.len(),\n                        non_responding.join(\", \")\n                    );\n                }\n\n                found_events\n            })\n        };\n\n        // 3. Send the events\n        for event in events_to_send {\n            let plugin = plugins\n                .iter()\n                .find(|p| p.ref_id == event.plugin_ref_id)\n                .expect(\"Didn't find plugin in list\");\n            plugin.send(&event).await?\n        }\n\n        // 4. Join on the spawned thread\n        let events = sub_events_fut.await.expect(\"Thread didn't succeed\");\n\n        // 5. Unsubscribe\n        self.unsubscribe(&rx_id).await;\n\n        Ok(events)\n    }\n\n    pub async fn get_themes(\n        &self,\n        plugin_context: &PluginContext,\n    ) -> Result<Vec<GetThemesResponse>> {\n        let reply_events = self\n            .send_and_wait(\n                plugin_context,\n                &InternalEventPayload::GetThemesRequest(GetThemesRequest {}),\n                Duration::from_secs(5),\n            )\n            .await?;\n\n        let mut themes = Vec::new();\n        for event in reply_events {\n            if let InternalEventPayload::GetThemesResponse(resp) = event.payload {\n                themes.push(resp.clone());\n            }\n        }\n\n        Ok(themes)\n    }\n\n    pub async fn get_grpc_request_actions(\n        &self,\n        plugin_context: &PluginContext,\n    ) -> Result<Vec<GetGrpcRequestActionsResponse>> {\n        let reply_events = self\n            .send_and_wait(\n                plugin_context,\n                &InternalEventPayload::GetGrpcRequestActionsRequest(EmptyPayload {}),\n                Duration::from_secs(5),\n            )\n            .await?;\n\n        let mut all_actions = Vec::new();\n        for event in reply_events {\n            if let InternalEventPayload::GetGrpcRequestActionsResponse(resp) = event.payload {\n                all_actions.push(resp.clone());\n            }\n        }\n\n        Ok(all_actions)\n    }\n\n    pub async fn get_http_request_actions(\n        &self,\n        plugin_context: &PluginContext,\n    ) -> Result<Vec<GetHttpRequestActionsResponse>> {\n        let reply_events = self\n            .send_and_wait(\n                plugin_context,\n                &InternalEventPayload::GetHttpRequestActionsRequest(EmptyPayload {}),\n                Duration::from_secs(5),\n            )\n            .await?;\n\n        let mut all_actions = Vec::new();\n        for event in reply_events {\n            if let InternalEventPayload::GetHttpRequestActionsResponse(resp) = event.payload {\n                all_actions.push(resp.clone());\n            }\n        }\n\n        Ok(all_actions)\n    }\n\n    pub async fn get_websocket_request_actions(\n        &self,\n        plugin_context: &PluginContext,\n    ) -> Result<Vec<GetWebsocketRequestActionsResponse>> {\n        let reply_events = self\n            .send_and_wait(\n                plugin_context,\n                &InternalEventPayload::GetWebsocketRequestActionsRequest(EmptyPayload {}),\n                Duration::from_secs(5),\n            )\n            .await?;\n\n        let mut all_actions = Vec::new();\n        for event in reply_events {\n            if let InternalEventPayload::GetWebsocketRequestActionsResponse(resp) = event.payload {\n                all_actions.push(resp.clone());\n            }\n        }\n\n        Ok(all_actions)\n    }\n\n    pub async fn get_workspace_actions(\n        &self,\n        plugin_context: &PluginContext,\n    ) -> Result<Vec<GetWorkspaceActionsResponse>> {\n        let reply_events = self\n            .send_and_wait(\n                plugin_context,\n                &InternalEventPayload::GetWorkspaceActionsRequest(EmptyPayload {}),\n                Duration::from_secs(5),\n            )\n            .await?;\n\n        let mut all_actions = Vec::new();\n        for event in reply_events {\n            if let InternalEventPayload::GetWorkspaceActionsResponse(resp) = event.payload {\n                all_actions.push(resp.clone());\n            }\n        }\n\n        Ok(all_actions)\n    }\n\n    pub async fn get_folder_actions(\n        &self,\n        plugin_context: &PluginContext,\n    ) -> Result<Vec<GetFolderActionsResponse>> {\n        let reply_events = self\n            .send_and_wait(\n                plugin_context,\n                &InternalEventPayload::GetFolderActionsRequest(EmptyPayload {}),\n                Duration::from_secs(5),\n            )\n            .await?;\n\n        let mut all_actions = Vec::new();\n        for event in reply_events {\n            if let InternalEventPayload::GetFolderActionsResponse(resp) = event.payload {\n                all_actions.push(resp.clone());\n            }\n        }\n\n        Ok(all_actions)\n    }\n\n    /// Get template function config.\n    /// Note: Values should be pre-rendered by the caller if needed.\n    pub async fn get_template_function_config(\n        &self,\n        plugin_context: &PluginContext,\n        fn_name: &str,\n        rendered_values: HashMap<String, JsonPrimitive>,\n        model_id: &str,\n    ) -> Result<GetTemplateFunctionConfigResponse> {\n        let results = self.get_template_function_summaries(plugin_context).await?;\n        let r = results\n            .iter()\n            .find(|r| r.functions.iter().any(|f| f.name == fn_name))\n            .ok_or_else(|| PluginNotFoundErr(fn_name.into()))?;\n\n        let plugin = match self.get_plugin_by_ref_id(&r.plugin_ref_id).await {\n            None => {\n                // It's probably a native function, so just fallback to the summary\n                let function = r\n                    .functions\n                    .iter()\n                    .find(|f| f.name == fn_name)\n                    .ok_or_else(|| PluginNotFoundErr(fn_name.into()))?;\n                return Ok(GetTemplateFunctionConfigResponse {\n                    function: function.clone(),\n                    plugin_ref_id: r.plugin_ref_id.clone(),\n                });\n            }\n            Some(v) => v,\n        };\n\n        let context_id = format!(\"{:x}\", md5::compute(model_id));\n\n        let event = self\n            .send_to_plugin_and_wait(\n                plugin_context,\n                &plugin,\n                &InternalEventPayload::GetTemplateFunctionConfigRequest(\n                    GetTemplateFunctionConfigRequest {\n                        values: rendered_values,\n                        name: fn_name.to_string(),\n                        context_id,\n                    },\n                ),\n                Duration::from_secs(5),\n            )\n            .await?;\n        match event.payload {\n            InternalEventPayload::GetTemplateFunctionConfigResponse(resp) => Ok(resp),\n            InternalEventPayload::EmptyResponse(_) => {\n                Err(PluginErr(\"Template function plugin returned empty\".to_string()))\n            }\n            InternalEventPayload::ErrorResponse(e) => Err(PluginErr(e.error)),\n            e => Err(PluginErr(format!(\"Template function plugin returned invalid event {:?}\", e))),\n        }\n    }\n\n    pub async fn call_http_request_action(\n        &self,\n        plugin_context: &PluginContext,\n        req: CallHttpRequestActionRequest,\n    ) -> Result<()> {\n        let ref_id = req.plugin_ref_id.clone();\n        let plugin =\n            self.get_plugin_by_ref_id(ref_id.as_str()).await.ok_or(PluginNotFoundErr(ref_id))?;\n        let event = plugin.build_event_to_send(\n            plugin_context,\n            &InternalEventPayload::CallHttpRequestActionRequest(req),\n            None,\n        );\n        plugin.send(&event).await?;\n        Ok(())\n    }\n\n    pub async fn call_websocket_request_action(\n        &self,\n        plugin_context: &PluginContext,\n        req: CallWebsocketRequestActionRequest,\n    ) -> Result<()> {\n        let ref_id = req.plugin_ref_id.clone();\n        let plugin =\n            self.get_plugin_by_ref_id(ref_id.as_str()).await.ok_or(PluginNotFoundErr(ref_id))?;\n        let event = plugin.build_event_to_send(\n            plugin_context,\n            &InternalEventPayload::CallWebsocketRequestActionRequest(req),\n            None,\n        );\n        plugin.send(&event).await?;\n        Ok(())\n    }\n\n    pub async fn call_workspace_action(\n        &self,\n        plugin_context: &PluginContext,\n        req: CallWorkspaceActionRequest,\n    ) -> Result<()> {\n        let ref_id = req.plugin_ref_id.clone();\n        let plugin =\n            self.get_plugin_by_ref_id(ref_id.as_str()).await.ok_or(PluginNotFoundErr(ref_id))?;\n        let event = plugin.build_event_to_send(\n            plugin_context,\n            &InternalEventPayload::CallWorkspaceActionRequest(req),\n            None,\n        );\n        plugin.send(&event).await?;\n        Ok(())\n    }\n\n    pub async fn call_folder_action(\n        &self,\n        plugin_context: &PluginContext,\n        req: CallFolderActionRequest,\n    ) -> Result<()> {\n        let ref_id = req.plugin_ref_id.clone();\n        let plugin =\n            self.get_plugin_by_ref_id(ref_id.as_str()).await.ok_or(PluginNotFoundErr(ref_id))?;\n        let event = plugin.build_event_to_send(\n            plugin_context,\n            &InternalEventPayload::CallFolderActionRequest(req),\n            None,\n        );\n        plugin.send(&event).await?;\n        Ok(())\n    }\n\n    pub async fn call_grpc_request_action(\n        &self,\n        plugin_context: &PluginContext,\n        req: CallGrpcRequestActionRequest,\n    ) -> Result<()> {\n        let ref_id = req.plugin_ref_id.clone();\n        let plugin =\n            self.get_plugin_by_ref_id(ref_id.as_str()).await.ok_or(PluginNotFoundErr(ref_id))?;\n        let event = plugin.build_event_to_send(\n            plugin_context,\n            &InternalEventPayload::CallGrpcRequestActionRequest(req),\n            None,\n        );\n        plugin.send(&event).await?;\n        Ok(())\n    }\n\n    pub async fn get_http_authentication_summaries(\n        &self,\n        plugin_context: &PluginContext,\n    ) -> Result<Vec<(PluginHandle, GetHttpAuthenticationSummaryResponse)>> {\n        let reply_events = self\n            .send_and_wait(\n                plugin_context,\n                &InternalEventPayload::GetHttpAuthenticationSummaryRequest(EmptyPayload {}),\n                Duration::from_secs(5),\n            )\n            .await?;\n\n        let mut results = Vec::new();\n        for event in reply_events {\n            if let InternalEventPayload::GetHttpAuthenticationSummaryResponse(resp) = event.payload\n            {\n                let plugin = self\n                    .get_plugin_by_ref_id(&event.plugin_ref_id)\n                    .await\n                    .ok_or(PluginNotFoundErr(event.plugin_ref_id))?;\n                results.push((plugin, resp.clone()));\n            }\n        }\n\n        Ok(results)\n    }\n\n    /// Get HTTP authentication config.\n    /// Note: Values should be pre-rendered by the caller if needed.\n    pub async fn get_http_authentication_config(\n        &self,\n        plugin_context: &PluginContext,\n        auth_name: &str,\n        rendered_values: HashMap<String, JsonPrimitive>,\n        model_id: &str,\n    ) -> Result<GetHttpAuthenticationConfigResponse> {\n        if auth_name == \"none\" {\n            return Ok(GetHttpAuthenticationConfigResponse {\n                args: Vec::new(),\n                plugin_ref_id: \"auth-none\".to_string(),\n                actions: None,\n            });\n        }\n\n        let results = self.get_http_authentication_summaries(plugin_context).await?;\n        let plugin = results\n            .iter()\n            .find_map(|(p, r)| if r.name == auth_name { Some(p) } else { None })\n            .ok_or(PluginNotFoundErr(auth_name.into()))?;\n\n        let context_id = format!(\"{:x}\", md5::compute(model_id));\n        let event = self\n            .send_to_plugin_and_wait(\n                plugin_context,\n                &plugin,\n                &InternalEventPayload::GetHttpAuthenticationConfigRequest(\n                    GetHttpAuthenticationConfigRequest { values: rendered_values, context_id },\n                ),\n                Duration::from_secs(5),\n            )\n            .await?;\n        match event.payload {\n            InternalEventPayload::GetHttpAuthenticationConfigResponse(resp) => Ok(resp),\n            InternalEventPayload::EmptyResponse(_) => {\n                Err(PluginErr(\"Auth plugin returned empty\".to_string()))\n            }\n            InternalEventPayload::ErrorResponse(e) => Err(PluginErr(e.error)),\n            e => Err(PluginErr(format!(\"Auth plugin returned invalid event {:?}\", e))),\n        }\n    }\n\n    /// Call HTTP authentication action.\n    /// Note: Values should be pre-rendered by the caller if needed.\n    pub async fn call_http_authentication_action(\n        &self,\n        plugin_context: &PluginContext,\n        auth_name: &str,\n        action_index: i32,\n        rendered_values: HashMap<String, JsonPrimitive>,\n        model_id: &str,\n    ) -> Result<()> {\n        let results = self.get_http_authentication_summaries(plugin_context).await?;\n        let plugin = results\n            .iter()\n            .find_map(|(p, r)| if r.name == auth_name { Some(p) } else { None })\n            .ok_or(PluginNotFoundErr(auth_name.into()))?;\n\n        let context_id = format!(\"{:x}\", md5::compute(model_id));\n        self.send_to_plugin_and_wait(\n            plugin_context,\n            &plugin,\n            &InternalEventPayload::CallHttpAuthenticationActionRequest(\n                CallHttpAuthenticationActionRequest {\n                    index: action_index,\n                    plugin_ref_id: plugin.clone().ref_id,\n                    args: CallHttpAuthenticationActionArgs { context_id, values: rendered_values },\n                },\n            ),\n            Duration::from_secs(300), // 5 minutes for OAuth flows\n        )\n        .await?;\n        Ok(())\n    }\n\n    pub async fn call_http_authentication(\n        &self,\n        plugin_context: &PluginContext,\n        auth_name: &str,\n        req: CallHttpAuthenticationRequest,\n    ) -> Result<CallHttpAuthenticationResponse> {\n        let disabled = match req.values.get(\"disabled\") {\n            Some(JsonPrimitive::Boolean(v)) => *v,\n            _ => false,\n        };\n\n        // Auth is disabled, so don't do anything\n        if disabled {\n            info!(\"Not applying disabled auth {:?}\", auth_name);\n            return Ok(CallHttpAuthenticationResponse {\n                set_headers: None,\n                set_query_parameters: None,\n            });\n        }\n\n        let handlers = self.get_http_authentication_summaries(plugin_context).await?;\n        let (plugin, _) = handlers\n            .iter()\n            .find(|(_, a)| a.name == auth_name)\n            .ok_or(AuthPluginNotFound(auth_name.to_string()))?;\n\n        let event = self\n            .send_to_plugin_and_wait(\n                plugin_context,\n                &plugin,\n                &InternalEventPayload::CallHttpAuthenticationRequest(req),\n                Duration::from_secs(300), // 5 minutes for OAuth flows\n            )\n            .await?;\n        match event.payload {\n            InternalEventPayload::CallHttpAuthenticationResponse(resp) => Ok(resp),\n            InternalEventPayload::EmptyResponse(_) => {\n                Err(PluginErr(\"Auth plugin returned empty\".to_string()))\n            }\n            InternalEventPayload::ErrorResponse(e) => Err(PluginErr(e.error)),\n            e => Err(PluginErr(format!(\"Auth plugin returned invalid event {:?}\", e))),\n        }\n    }\n\n    pub async fn get_template_function_summaries(\n        &self,\n        plugin_context: &PluginContext,\n    ) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {\n        let reply_events = self\n            .send_and_wait(\n                plugin_context,\n                &InternalEventPayload::GetTemplateFunctionSummaryRequest(EmptyPayload {}),\n                Duration::from_secs(5),\n            )\n            .await?;\n\n        let mut results = Vec::new();\n        for event in reply_events {\n            if let InternalEventPayload::GetTemplateFunctionSummaryResponse(resp) = event.payload {\n                results.push(resp.clone());\n            }\n        }\n\n        // Add Rust-based functions\n        results.push(GetTemplateFunctionSummaryResponse {\n            plugin_ref_id: \"__NATIVE__\".to_string(), // Meh\n            functions: vec![template_function_secure(), template_function_keyring()],\n        });\n\n        Ok(results)\n    }\n\n    pub async fn call_template_function(\n        &self,\n        plugin_context: &PluginContext,\n        fn_name: &str,\n        values: HashMap<String, JsonPrimitive>,\n        purpose: RenderPurpose,\n    ) -> TemplateResult<String> {\n        let req = CallTemplateFunctionRequest {\n            name: fn_name.to_string(),\n            args: CallTemplateFunctionArgs { purpose, values },\n        };\n\n        let events = self\n            .send_and_wait(\n                plugin_context,\n                &InternalEventPayload::CallTemplateFunctionRequest(req),\n                Duration::from_secs(300), // 5 minutes for user interactions (OAuth, prompts, etc.)\n            )\n            .await\n            .map_err(|e| RenderError(format!(\"Failed to call template function {e:}\")))?;\n\n        let value =\n            events.into_iter().find_map(|e| match e.payload {\n                // Error returned\n                InternalEventPayload::CallTemplateFunctionResponse(\n                    CallTemplateFunctionResponse { error: Some(error), .. },\n                ) => Some(Err(error)),\n                // Value or null returned\n                InternalEventPayload::CallTemplateFunctionResponse(\n                    CallTemplateFunctionResponse { value, .. },\n                ) => Some(Ok(value.unwrap_or_default())),\n                // Generic error returned\n                InternalEventPayload::ErrorResponse(ErrorResponse { error }) => Some(Err(error)),\n                _ => None,\n            });\n\n        match value {\n            None => Err(RenderError(format!(\"Template function {fn_name}(…) not found \"))),\n            Some(Ok(v)) => Ok(v),\n            Some(Err(e)) => Err(RenderError(e)),\n        }\n    }\n\n    pub async fn import_data(\n        &self,\n        plugin_context: &PluginContext,\n        content: &str,\n    ) -> Result<ImportResponse> {\n        let reply_events = self\n            .send_and_wait(\n                plugin_context,\n                &InternalEventPayload::ImportRequest(ImportRequest {\n                    content: content.to_string(),\n                }),\n                Duration::from_secs(5),\n            )\n            .await?;\n\n        // TODO: Don't just return the first valid response\n        let result = reply_events.into_iter().find_map(|e| match e.payload {\n            InternalEventPayload::ImportResponse(resp) => Some(resp),\n            _ => None,\n        });\n\n        match result {\n            None => Err(PluginErr(\"No importers found for file contents\".to_string())),\n            Some(resp) => Ok(resp),\n        }\n    }\n\n    pub async fn filter_data(\n        &self,\n        plugin_context: &PluginContext,\n        filter: &str,\n        content: &str,\n        content_type: &str,\n    ) -> Result<FilterResponse> {\n        let ct = content_type.to_lowercase();\n        let plugin_name = if ct.contains(\"xml\") || ct.contains(\"html\") {\n            \"@yaak/filter-xpath\"\n        } else {\n            \"@yaak/filter-jsonpath\"\n        };\n\n        let plugin = self\n            .get_plugin_by_name(plugin_name)\n            .await\n            .ok_or(PluginNotFoundErr(plugin_name.to_string()))?;\n\n        let event = self\n            .send_to_plugin_and_wait(\n                plugin_context,\n                &plugin,\n                &InternalEventPayload::FilterRequest(FilterRequest {\n                    filter: filter.to_string(),\n                    content: content.to_string(),\n                }),\n                Duration::from_secs(5),\n            )\n            .await?;\n\n        match event.payload {\n            InternalEventPayload::FilterResponse(resp) => Ok(resp),\n            InternalEventPayload::EmptyResponse(_) => {\n                Err(PluginErr(\"Filter returned empty\".to_string()))\n            }\n            e => Err(PluginErr(format!(\"Export returned invalid event {:?}\", e))),\n        }\n    }\n}\n\nfn source_priority(source: &PluginSource) -> i32 {\n    match source {\n        PluginSource::Filesystem => 3,\n        PluginSource::Registry => 2,\n        PluginSource::Bundled => 1,\n    }\n}\n\nfn prefer_plugin(candidate: &Plugin, existing: &Plugin) -> bool {\n    let candidate_priority = source_priority(&candidate.source);\n    let existing_priority = source_priority(&existing.source);\n    if candidate_priority != existing_priority {\n        return candidate_priority > existing_priority;\n    }\n\n    candidate.created_at > existing.created_at\n}\n\nasync fn read_plugins_dir(dir: &PathBuf) -> Result<Vec<String>> {\n    let mut result = read_dir(dir).await?;\n    let mut dirs: Vec<String> = vec![];\n    while let Ok(Some(entry)) = result.next_entry().await {\n        if entry.path().is_dir() {\n            #[cfg(target_os = \"windows\")]\n            dirs.push(fix_windows_paths(&entry.path()));\n            #[cfg(not(target_os = \"windows\"))]\n            dirs.push(entry.path().to_string_lossy().to_string());\n        }\n    }\n    Ok(dirs)\n}\n\n#[cfg(target_os = \"windows\")]\nfn fix_windows_paths(p: &PathBuf) -> String {\n    use dunce;\n    use path_slash::PathBufExt;\n\n    // 1. Remove UNC prefix for Windows paths\n    let safe_path = dunce::simplified(p.as_path());\n\n    // 2. Convert backslashes to forward slashes for Node.js compatibility\n    PathBuf::from(safe_path).to_slash_lossy().to_string()\n}\n"
  },
  {
    "path": "crates/yaak-plugins/src/native_template_functions.rs",
    "content": "//! Native template functions implemented in Rust.\n//!\n//! These are built-in template functions that don't require plugins:\n//! - `secure()` - encrypts/decrypts values using the EncryptionManager\n//! - `keychain()` / `keyring()` - accesses system keychain\n\nuse crate::events::{\n    Color, FormInput, FormInputBanner, FormInputBase, FormInputMarkdown, FormInputText,\n    PluginContext, RenderPurpose, TemplateFunction, TemplateFunctionArg,\n    TemplateFunctionPreviewType,\n};\nuse crate::manager::PluginManager;\nuse crate::template_callback::PluginTemplateCallback;\nuse base64::Engine;\nuse base64::prelude::BASE64_STANDARD;\nuse keyring::Error::NoEntry;\nuse log::{debug, info};\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse yaak_common::platform::{OperatingSystem, get_os};\nuse yaak_crypto::manager::EncryptionManager;\nuse yaak_templates::error::Error::RenderError;\nuse yaak_templates::error::Result;\nuse yaak_templates::{FnArg, Parser, Token, Tokens, Val, transform_args};\n\npub(crate) fn template_function_secure() -> TemplateFunction {\n    TemplateFunction {\n        name: \"secure\".to_string(),\n        preview_type: Some(TemplateFunctionPreviewType::None),\n        description: Some(\"Securely store encrypted text\".to_string()),\n        aliases: None,\n        preview_args: None,\n        args: vec![TemplateFunctionArg::FormInput(FormInput::Text(\n            FormInputText {\n                multi_line: Some(true),\n                password: Some(true),\n                base: FormInputBase {\n                    name: \"value\".to_string(),\n                    label: Some(\"Value\".to_string()),\n                    ..Default::default()\n                },\n                ..Default::default()\n            },\n        ))],\n    }\n}\n\npub(crate) fn template_function_keyring() -> TemplateFunction {\n    struct Meta {\n        description: String,\n        service_label: String,\n        account_label: String,\n    }\n\n    let meta = match get_os() {\n        OperatingSystem::MacOS => Meta {\n            description:\n            \"Access application passwords from the macOS Login keychain\".to_string(),\n            service_label: \"Where\".to_string(),\n            account_label: \"Account\".to_string(),\n        },\n        OperatingSystem::Windows => Meta {\n            description: \"Access a secret via Windows Credential Manager\".to_string(),\n            service_label: \"Target\".to_string(),\n            account_label: \"Username\".to_string(),\n        },\n        _ => Meta {\n            description: \"Access a secret via [Secret Service](https://specifications.freedesktop.org/secret-service/latest/) (eg. Gnome keyring or KWallet)\".to_string(),\n            service_label: \"Collection\".to_string(),\n            account_label: \"Account\".to_string(),\n        },\n    };\n\n    TemplateFunction {\n        name: \"keychain\".to_string(),\n        preview_type: Some(TemplateFunctionPreviewType::Live),\n        description: Some(meta.description),\n        aliases: Some(vec![\"keyring\".to_string()]),\n        preview_args: Some(vec![\"service\".to_string(), \"account\".to_string()]),\n        args: vec![\n            TemplateFunctionArg::FormInput(FormInput::Banner(FormInputBanner {\n                inputs: Some(vec![FormInput::Markdown(FormInputMarkdown {\n                    content: \"For most cases, prefer the [`secure(…)`](https://yaak.app/help/encryption) template function, which encrypts values using a key stored in keychain\".to_string(),\n                    hidden: None,\n                })]),\n                color: Some(Color::Info),\n                hidden: None,\n            })),\n            TemplateFunctionArg::FormInput(FormInput::Text(FormInputText {\n                base: FormInputBase {\n                    name: \"service\".to_string(),\n                    label: Some(meta.service_label),\n                    description: Some(\"App or URL for the password\".to_string()),\n                    ..Default::default()\n                },\n                ..Default::default()\n            })),\n            TemplateFunctionArg::FormInput(FormInput::Text(FormInputText {\n                base: FormInputBase {\n                    name: \"account\".to_string(),\n                    label: Some(meta.account_label),\n                    description: Some(\"Username or email address\".to_string()),\n                    ..Default::default()\n                },\n                ..Default::default()\n            })),\n        ],\n    }\n}\n\npub fn template_function_secure_run(\n    encryption_manager: &EncryptionManager,\n    args: HashMap<String, serde_json::Value>,\n    plugin_context: &PluginContext,\n) -> Result<String> {\n    match plugin_context.workspace_id.clone() {\n        Some(wid) => {\n            let value = args.get(\"value\").map(|v| v.to_owned()).unwrap_or_default();\n            let value = match value {\n                serde_json::Value::String(s) => s,\n                _ => return Ok(\"\".to_string()),\n            };\n\n            if value.is_empty() {\n                return Ok(\"\".to_string());\n            }\n\n            let value = match value.strip_prefix(\"YENC_\") {\n                None => {\n                    return Err(RenderError(\"Could not decrypt non-encrypted value\".to_string()));\n                }\n                Some(v) => v,\n            };\n\n            let value = BASE64_STANDARD.decode(&value).unwrap();\n            let r = match encryption_manager.decrypt(&wid, value.as_slice()) {\n                Ok(r) => Ok(r),\n                Err(e) => Err(RenderError(e.to_string())),\n            }?;\n            let r = String::from_utf8(r).map_err(|e| RenderError(e.to_string()))?;\n            Ok(r)\n        }\n        _ => Err(RenderError(\"workspace_id missing from plugin context\".to_string())),\n    }\n}\n\npub fn template_function_secure_transform_arg(\n    encryption_manager: &EncryptionManager,\n    plugin_context: &PluginContext,\n    arg_name: &str,\n    arg_value: &str,\n) -> Result<String> {\n    if arg_name != \"value\" {\n        return Ok(arg_value.to_string());\n    }\n\n    match plugin_context.workspace_id.clone() {\n        Some(wid) => {\n            if arg_value.is_empty() {\n                return Ok(\"\".to_string());\n            }\n\n            if arg_value.starts_with(\"YENC_\") {\n                // Already encrypted, so do nothing\n                return Ok(arg_value.to_string());\n            }\n\n            let r = encryption_manager\n                .encrypt(&wid, arg_value.as_bytes())\n                .map_err(|e| RenderError(e.to_string()))?;\n            let r = BASE64_STANDARD.encode(r);\n            Ok(format!(\"YENC_{}\", r))\n        }\n        _ => Err(RenderError(\"workspace_id missing from plugin context\".to_string())),\n    }\n}\n\npub fn decrypt_secure_template_function(\n    encryption_manager: &EncryptionManager,\n    plugin_context: &PluginContext,\n    template: &str,\n) -> Result<String> {\n    let mut parsed = Parser::new(template).parse()?;\n    let mut new_tokens: Vec<Token> = Vec::new();\n\n    for token in parsed.tokens.iter() {\n        match token {\n            Token::Tag { val: Val::Fn { name, args } } if name == \"secure\" => {\n                let mut args_map = HashMap::new();\n                for a in args {\n                    match a.clone().value {\n                        Val::Str { text } => {\n                            args_map.insert(a.name.to_string(), serde_json::Value::String(text));\n                        }\n                        _ => continue,\n                    }\n                }\n                new_tokens.push(Token::Raw {\n                    text: template_function_secure_run(\n                        encryption_manager,\n                        args_map,\n                        plugin_context,\n                    )?,\n                });\n            }\n            t => {\n                new_tokens.push(t.clone());\n                continue;\n            }\n        };\n    }\n\n    parsed.tokens = new_tokens;\n    Ok(parsed.to_string())\n}\n\npub fn encrypt_secure_template_function(\n    plugin_manager: Arc<PluginManager>,\n    encryption_manager: Arc<EncryptionManager>,\n    plugin_context: &PluginContext,\n    template: &str,\n) -> Result<String> {\n    let decrypted =\n        decrypt_secure_template_function(&encryption_manager, plugin_context, template)?;\n    let tokens = Tokens {\n        tokens: vec![Token::Tag {\n            val: Val::Fn {\n                name: \"secure\".to_string(),\n                args: vec![FnArg {\n                    name: \"value\".to_string(),\n                    value: Val::Str { text: decrypted },\n                }],\n            },\n        }],\n    };\n\n    Ok(transform_args(\n        tokens,\n        &PluginTemplateCallback::new(\n            plugin_manager,\n            encryption_manager,\n            plugin_context,\n            RenderPurpose::Preview,\n        ),\n    )?\n    .to_string())\n}\n\npub fn template_function_keychain_run(args: HashMap<String, serde_json::Value>) -> Result<String> {\n    let service = args.get(\"service\").and_then(|v| v.as_str()).unwrap_or_default().to_owned();\n    let user = args.get(\"account\").and_then(|v| v.as_str()).unwrap_or_default().to_owned();\n    debug!(\"Getting password for service {} and user {}\", service, user);\n    let entry = match keyring::Entry::new(&service, &user) {\n        Ok(e) => e,\n        Err(e) => {\n            debug!(\"Failed to initialize keyring entry for '{}' and '{}' {:?}\", service, user, e);\n            return Ok(\"\".to_string()); // Don't fail for invalid args\n        }\n    };\n\n    match entry.get_password() {\n        Ok(p) => Ok(p),\n        Err(NoEntry) => {\n            info!(\"No password found for '{}' and '{}'\", service, user);\n            Ok(\"\".to_string()) // Don't fail for missing passwords\n        }\n        Err(e) => Err(RenderError(e.to_string())),\n    }\n}\n"
  },
  {
    "path": "crates/yaak-plugins/src/nodejs.rs",
    "content": "use crate::error::Result;\nuse log::{info, warn};\nuse std::net::SocketAddr;\nuse std::path::Path;\nuse std::process::Stdio;\nuse tokio::io::{AsyncBufReadExt, BufReader};\nuse tokio::sync::oneshot;\nuse tokio::sync::watch::Receiver;\nuse yaak_common::command::new_xplatform_command;\n\n/// Start the Node.js plugin runtime process.\n///\n/// # Arguments\n/// * `node_bin_path` - Path to the yaaknode binary\n/// * `plugin_runtime_main` - Path to the plugin runtime index.cjs\n/// * `addr` - Socket address for the plugin runtime to connect to\n/// * `kill_rx` - Channel to signal shutdown\npub async fn start_nodejs_plugin_runtime(\n    node_bin_path: &Path,\n    plugin_runtime_main: &Path,\n    addr: SocketAddr,\n    kill_rx: &Receiver<bool>,\n    killed_tx: oneshot::Sender<()>,\n) -> Result<()> {\n    // HACK: Remove UNC prefix for Windows paths to pass to sidecar\n    let plugin_runtime_main_str =\n        dunce::simplified(plugin_runtime_main).to_string_lossy().to_string();\n\n    info!(\n        \"Starting plugin runtime node={} main={}\",\n        node_bin_path.display(),\n        plugin_runtime_main_str\n    );\n\n    let mut cmd = new_xplatform_command(node_bin_path);\n    cmd.env(\"HOST\", addr.ip().to_string())\n        .env(\"PORT\", addr.port().to_string())\n        .arg(&plugin_runtime_main_str)\n        .stdout(Stdio::piped())\n        .stderr(Stdio::piped());\n\n    let mut child = cmd.spawn()?;\n\n    info!(\"Spawned plugin runtime\");\n\n    // Stream stdout\n    if let Some(stdout) = child.stdout.take() {\n        tokio::spawn(async move {\n            let reader = BufReader::new(stdout);\n            let mut lines = reader.lines();\n            while let Ok(Some(line)) = lines.next_line().await {\n                info!(\"{}\", line);\n            }\n        });\n    }\n\n    // Stream stderr\n    if let Some(stderr) = child.stderr.take() {\n        tokio::spawn(async move {\n            let reader = BufReader::new(stderr);\n            let mut lines = reader.lines();\n            while let Ok(Some(line)) = lines.next_line().await {\n                warn!(\"{}\", line);\n            }\n        });\n    }\n\n    // Handle kill signal\n    let mut kill_rx = kill_rx.clone();\n    tokio::spawn(async move {\n        if kill_rx.wait_for(|b| *b == true).await.is_err() {\n            warn!(\"Kill channel closed before explicit shutdown; terminating plugin runtime\");\n        }\n        info!(\"Killing plugin runtime\");\n        if let Err(e) = child.kill().await {\n            warn!(\"Failed to kill plugin runtime: {e}\");\n        }\n        info!(\"Killed plugin runtime\");\n        let _ = killed_tx.send(());\n    });\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/yaak-plugins/src/plugin_handle.rs",
    "content": "use crate::error::Result;\nuse crate::events::{InternalEvent, InternalEventPayload, PluginContext};\nuse crate::plugin_meta::{PluginMetadata, get_plugin_meta};\nuse crate::util::gen_id;\nuse std::path::Path;\nuse std::sync::Arc;\nuse tokio::sync::{Mutex, mpsc};\n\n#[derive(Clone)]\npub struct PluginHandle {\n    pub ref_id: String,\n    pub dir: String,\n    pub enabled: bool,\n    pub(crate) to_plugin_tx: Arc<Mutex<mpsc::Sender<InternalEvent>>>,\n    pub(crate) metadata: PluginMetadata,\n}\n\nimpl PluginHandle {\n    pub fn new(dir: &str, enabled: bool, tx: mpsc::Sender<InternalEvent>) -> Result<Self> {\n        let ref_id = gen_id();\n        let metadata = get_plugin_meta(&Path::new(dir))?;\n\n        Ok(PluginHandle {\n            ref_id: ref_id.clone(),\n            dir: dir.to_string(),\n            to_plugin_tx: Arc::new(Mutex::new(tx)),\n            enabled,\n            metadata,\n        })\n    }\n\n    pub fn info(&self) -> PluginMetadata {\n        self.metadata.clone()\n    }\n\n    pub fn build_event_to_send(\n        &self,\n        plugin_context: &PluginContext,\n        payload: &InternalEventPayload,\n        reply_id: Option<String>,\n    ) -> InternalEvent {\n        self.build_event_to_send_raw(plugin_context, payload, reply_id)\n    }\n\n    pub(crate) fn build_event_to_send_raw(\n        &self,\n        plugin_context: &PluginContext,\n        payload: &InternalEventPayload,\n        reply_id: Option<String>,\n    ) -> InternalEvent {\n        let dir = Path::new(&self.dir);\n        InternalEvent {\n            id: gen_id(),\n            plugin_ref_id: self.ref_id.clone(),\n            plugin_name: dir.file_name().unwrap().to_str().unwrap().to_string(),\n            reply_id,\n            payload: payload.clone(),\n            context: plugin_context.clone(),\n        }\n    }\n\n    pub async fn send(&self, event: &InternalEvent) -> Result<()> {\n        self.to_plugin_tx.lock().await.send(event.to_owned()).await?;\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/yaak-plugins/src/plugin_meta.rs",
    "content": "use crate::error::Result;\nuse serde::{Deserialize, Serialize};\nuse std::fs;\nuse std::path::Path;\nuse ts_rs::TS;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_search.ts\")]\npub struct PluginMetadata {\n    pub version: String,\n    pub name: String,\n    pub display_name: String,\n    pub description: Option<String>,\n    pub homepage_url: Option<String>,\n    pub repository_url: Option<String>,\n}\n\npub fn get_plugin_meta(plugin_dir: &Path) -> Result<PluginMetadata> {\n    let package_json = fs::File::open(plugin_dir.join(\"package.json\"))?;\n    let package_json: PackageJson = serde_json::from_reader(package_json)?;\n\n    let display_name = match package_json.display_name {\n        None => {\n            let display_name = package_json.name.to_string();\n            let display_name = display_name.split('/').last().unwrap_or(&package_json.name);\n            let display_name = display_name.strip_prefix(\"yaak-plugin-\").unwrap_or(&display_name);\n            let display_name = display_name.strip_prefix(\"yaak-\").unwrap_or(&display_name);\n            display_name.to_string()\n        }\n        Some(n) => n,\n    };\n\n    Ok(PluginMetadata {\n        version: package_json.version,\n        description: package_json.description,\n        name: package_json.name,\n        display_name,\n        homepage_url: package_json.homepage,\n        repository_url: match package_json.repository {\n            None => None,\n            Some(RepositoryField::Object { url }) => Some(url),\n            Some(RepositoryField::String(url)) => Some(url),\n        },\n    })\n}\n\n#[derive(Debug, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct PackageJson {\n    pub name: String,\n    pub display_name: Option<String>,\n    pub version: String,\n    pub repository: Option<RepositoryField>,\n    pub homepage: Option<String>,\n    pub description: Option<String>,\n}\n\n#[derive(Debug, Deserialize)]\n#[serde(untagged)]\nenum RepositoryField {\n    String(String),\n    Object { url: String },\n}\n"
  },
  {
    "path": "crates/yaak-plugins/src/server_ws.rs",
    "content": "use crate::events::{ErrorResponse, InternalEvent, InternalEventPayload, InternalEventRawPayload};\nuse futures_util::{SinkExt, StreamExt};\nuse log::{error, info, warn};\nuse std::sync::Arc;\nuse tokio::net::{TcpListener, TcpStream};\nuse tokio::sync::{Mutex, mpsc};\nuse tokio_tungstenite::accept_async_with_config;\nuse tokio_tungstenite::tungstenite::Message;\nuse tokio_tungstenite::tungstenite::protocol::WebSocketConfig;\n\n#[derive(Clone)]\npub(crate) struct PluginRuntimeServerWebsocket {\n    pub(crate) app_to_plugin_events_tx: Arc<Mutex<Option<mpsc::Sender<InternalEvent>>>>,\n    client_disconnect_tx: mpsc::Sender<bool>,\n    client_connect_tx: tokio::sync::watch::Sender<bool>,\n    plugin_to_app_events_tx: mpsc::Sender<InternalEvent>,\n}\n\nimpl PluginRuntimeServerWebsocket {\n    pub fn new(\n        events_tx: mpsc::Sender<InternalEvent>,\n        disconnect_tx: mpsc::Sender<bool>,\n        connect_tx: tokio::sync::watch::Sender<bool>,\n    ) -> Self {\n        PluginRuntimeServerWebsocket {\n            app_to_plugin_events_tx: Arc::new(Mutex::new(None)),\n            client_disconnect_tx: disconnect_tx,\n            client_connect_tx: connect_tx,\n            plugin_to_app_events_tx: events_tx,\n        }\n    }\n\n    pub async fn listen(&self, listener: TcpListener) {\n        while let Ok((stream, _)) = listener.accept().await {\n            self.accept_connection(stream).await;\n        }\n    }\n\n    async fn accept_connection(&self, stream: TcpStream) {\n        let (to_plugin_tx, mut to_plugin_rx) = mpsc::channel::<InternalEvent>(2048);\n        let mut app_to_plugin_events_tx = self.app_to_plugin_events_tx.lock().await;\n        *app_to_plugin_events_tx = Some(to_plugin_tx);\n\n        let plugin_to_app_events_tx = self.plugin_to_app_events_tx.clone();\n        let client_disconnect_tx = self.client_disconnect_tx.clone();\n        let client_connect_tx = self.client_connect_tx.clone();\n\n        let addr = stream.peer_addr().expect(\"connected streams should have a peer address\");\n\n        let conf = WebSocketConfig::default();\n        let ws_stream = accept_async_with_config(stream, Some(conf))\n            .await\n            .expect(\"Error during the websocket handshake occurred\");\n\n        let (mut ws_sender, mut ws_receiver) = ws_stream.split();\n\n        tokio::spawn(async move {\n            client_connect_tx.send(true).expect(\"Failed to send client ready event\");\n\n            info!(\"New plugin runtime websocket connection: {}\", addr);\n\n            loop {\n                tokio::select! {\n                    msg = ws_receiver.next() => {\n                        let msg = match msg {\n                            Some(Ok(msg)) => msg,\n                            Some(Err(e)) => {\n                                warn!(\"Websocket error {e:?}\");\n                                continue;\n                            }\n                            None => break,\n                        };\n\n                        // Skip non-text messages\n                        if !msg.is_text() {\n                            warn!(\"Received non-text message from plugin runtime\");\n                            continue;\n                        }\n\n                        let msg_text = match msg.into_text() {\n                            Ok(text) => text,\n                            Err(e) => {\n                                error!(\"Failed to convert message to text: {e:?}\");\n                                continue;\n                            }\n                        };\n                        let event = match serde_json::from_str::<InternalEventRawPayload>(&msg_text) {\n                            Ok(e) => e,\n                            Err(e) => {\n                                error!(\"Failed to decode plugin event {e:?} -> {msg_text}\");\n                                continue;\n                            }\n                        };\n\n                        // Parse everything but the payload so we can catch errors on that, specifically\n                        let payload = serde_json::from_value::<InternalEventPayload>(event.payload.clone())\n                            .unwrap_or_else(|e| {\n                                warn!(\"Plugin event parse error from {}: {:?} {}\", event.plugin_name, e, event.payload);\n                                InternalEventPayload::ErrorResponse(ErrorResponse {\n                                    error: format!(\"Plugin event parse error from {}: {e:?}\", event.plugin_name),\n                                })\n                            });\n\n                        let event = InternalEvent{\n                            id: event.id,\n                            payload,\n                            plugin_ref_id: event.plugin_ref_id,\n                            plugin_name: event.plugin_name,\n                            context: event.context,\n                            reply_id: event.reply_id,\n                        };\n\n                        // Send event to subscribers\n                        // Emit event to the channel for server to handle\n                        if let Err(e) = plugin_to_app_events_tx.try_send(event) {\n                            warn!(\"Failed to send to channel. Receiver probably isn't listening: {:?}\", e);\n                        }\n                    }\n\n                    event_for_plugin = to_plugin_rx.recv() => {\n                        match event_for_plugin {\n                            None => {\n                                error!(\"Plugin runtime client WS channel closed\");\n                                return;\n                            },\n                            Some(event) => {\n                                let event_bytes = match serde_json::to_string(&event) {\n                                    Ok(bytes) => bytes,\n                                    Err(e) => {\n                                        error!(\"Failed to serialize event: {:?}\", e);\n                                        continue;\n                                    }\n                                };\n                                let msg = Message::text(event_bytes);\n                                if let Err(e) = ws_sender.send(msg).await {\n                                    error!(\"Failed to send message to plugin runtime: {:?}\", e);\n                                    break;\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n\n            if let Err(e) = client_disconnect_tx.send(true).await {\n                warn!(\"Failed to send killed event {:?}\", e);\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "crates/yaak-plugins/src/template_callback.rs",
    "content": "//! Plugin template callback implementation.\n//!\n//! This provides a TemplateCallback implementation that delegates to plugins\n//! for template function execution.\n\nuse crate::events::{JsonPrimitive, PluginContext, RenderPurpose};\nuse crate::manager::PluginManager;\nuse crate::native_template_functions::{\n    template_function_keychain_run, template_function_secure_run,\n    template_function_secure_transform_arg,\n};\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse yaak_crypto::manager::EncryptionManager;\nuse yaak_templates::TemplateCallback;\nuse yaak_templates::error::Result;\n\n#[derive(Clone)]\npub struct PluginTemplateCallback {\n    plugin_manager: Arc<PluginManager>,\n    encryption_manager: Arc<EncryptionManager>,\n    render_purpose: RenderPurpose,\n    plugin_context: PluginContext,\n}\n\nimpl PluginTemplateCallback {\n    pub fn new(\n        plugin_manager: Arc<PluginManager>,\n        encryption_manager: Arc<EncryptionManager>,\n        plugin_context: &PluginContext,\n        render_purpose: RenderPurpose,\n    ) -> PluginTemplateCallback {\n        PluginTemplateCallback {\n            plugin_manager,\n            encryption_manager,\n            render_purpose,\n            plugin_context: plugin_context.to_owned(),\n        }\n    }\n}\n\nimpl TemplateCallback for PluginTemplateCallback {\n    async fn run(&self, fn_name: &str, args: HashMap<String, serde_json::Value>) -> Result<String> {\n        // The beta named the function `Response` but was changed in stable.\n        // Keep this here for a while because there's no easy way to migrate\n        let fn_name = if fn_name == \"Response\" { \"response\" } else { fn_name };\n\n        if fn_name == \"secure\" {\n            return template_function_secure_run(\n                &self.encryption_manager,\n                args,\n                &self.plugin_context,\n            );\n        } else if fn_name == \"keychain\" || fn_name == \"keyring\" {\n            return template_function_keychain_run(args);\n        }\n\n        let mut primitive_args = HashMap::new();\n        for (key, value) in args {\n            primitive_args.insert(key, JsonPrimitive::from(value));\n        }\n\n        let resp = self\n            .plugin_manager\n            .call_template_function(\n                &self.plugin_context,\n                fn_name,\n                primitive_args,\n                self.render_purpose.to_owned(),\n            )\n            .await?;\n        Ok(resp)\n    }\n\n    fn transform_arg(&self, fn_name: &str, arg_name: &str, arg_value: &str) -> Result<String> {\n        if fn_name == \"secure\" {\n            return template_function_secure_transform_arg(\n                &self.encryption_manager,\n                &self.plugin_context,\n                arg_name,\n                arg_value,\n            );\n        }\n\n        Ok(arg_value.to_string())\n    }\n}\n"
  },
  {
    "path": "crates/yaak-plugins/src/util.rs",
    "content": "use rand::Rng;\nuse rand::distr::Alphanumeric;\n\npub fn gen_id() -> String {\n    rand::rng().sample_iter(&Alphanumeric).take(5).map(char::from).collect()\n}\n"
  },
  {
    "path": "crates/yaak-sse/Cargo.toml",
    "content": "[package]\nname = \"yaak-sse\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nserde = { workspace = true, features = [\"derive\"] }\nts-rs = { workspace = true, features = [\"serde-json-impl\"] }\n"
  },
  {
    "path": "crates/yaak-sse/bindings/sse.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type ServerSentEvent = { eventType: string, data: string, id: string | null, retry: bigint | null, };\n"
  },
  {
    "path": "crates/yaak-sse/index.ts",
    "content": "export * from \"./bindings/sse\";\n"
  },
  {
    "path": "crates/yaak-sse/package.json",
    "content": "{\n  \"name\": \"@yaakapp-internal/sse\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"index.ts\"\n}\n"
  },
  {
    "path": "crates/yaak-sse/src/lib.rs",
    "content": "pub mod sse;\n"
  },
  {
    "path": "crates/yaak-sse/src/sse.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse ts_rs::TS;\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"sse.ts\")]\npub struct ServerSentEvent {\n    pub event_type: String,\n    pub data: String,\n    pub id: Option<String>,\n    pub retry: Option<u64>,\n}\n"
  },
  {
    "path": "crates/yaak-sync/Cargo.toml",
    "content": "[package]\nname = \"yaak-sync\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nchrono = { workspace = true, features = [\"serde\"] }\nhex = { workspace = true }\nlog = { workspace = true }\nnotify = \"8.0.0\"\nserde = { workspace = true, features = [\"derive\"] }\nserde_json = { workspace = true }\nserde_yaml = \"0.9.34\"\nsha1 = \"0.10.6\"\nthiserror = { workspace = true }\ntokio = { workspace = true, features = [\"fs\", \"sync\", \"macros\", \"rt\"] }\nts-rs = { workspace = true, features = [\"chrono-impl\", \"serde-json-impl\"] }\nyaak-models = { workspace = true }\nserde_path_to_error = \"0.1.20\"\n"
  },
  {
    "path": "crates/yaak-sync/bindings/gen_models.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };\n\nexport type Environment = { model: \"environment\", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };\n\nexport type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };\n\nexport type Folder = { model: \"folder\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };\n\nexport type GrpcRequest = { model: \"grpc_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };\n\nexport type HttpRequest = { model: \"http_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };\n\nexport type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };\n\nexport type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, };\n\nexport type SyncModel = { \"type\": \"workspace\" } & Workspace | { \"type\": \"environment\" } & Environment | { \"type\": \"folder\" } & Folder | { \"type\": \"http_request\" } & HttpRequest | { \"type\": \"grpc_request\" } & GrpcRequest | { \"type\": \"websocket_request\" } & WebsocketRequest;\n\nexport type SyncState = { model: \"sync_state\", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };\n\nexport type WebsocketRequest = { model: \"websocket_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };\n\nexport type Workspace = { model: \"workspace\", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };\n"
  },
  {
    "path": "crates/yaak-sync/bindings/gen_sync.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\nimport type { SyncModel, SyncState } from \"./gen_models\";\n\nexport type FsCandidate = { \"type\": \"FsCandidate\", model: SyncModel, relPath: string, checksum: string, };\n\nexport type SyncOp = { \"type\": \"fsCreate\", model: SyncModel, } | { \"type\": \"fsUpdate\", model: SyncModel, state: SyncState, } | { \"type\": \"fsDelete\", state: SyncState, fs: FsCandidate | null, } | { \"type\": \"dbCreate\", fs: FsCandidate, } | { \"type\": \"dbUpdate\", state: SyncState, fs: FsCandidate, } | { \"type\": \"dbDelete\", model: SyncModel, state: SyncState, } | { \"type\": \"ignorePrivate\", model: SyncModel, };\n"
  },
  {
    "path": "crates/yaak-sync/bindings/gen_watch.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type WatchEvent = { paths: Array<string>, kind: string, };\n"
  },
  {
    "path": "crates/yaak-sync/bindings/git.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type GitCommit = { author: string, when: string, message: string | null, };\n\nexport type GitStatus = \"added\" | \"conflict\" | \"current\" | \"modified\" | \"removed\" | \"renamed\" | \"type_change\";\n\nexport type GitStatusEntry = { relaPath: string, status: GitStatus, staged: boolean, prev: string | null, next: string | null, };\n"
  },
  {
    "path": "crates/yaak-sync/index.ts",
    "content": "import { Channel, invoke } from \"@tauri-apps/api/core\";\nimport { emit } from \"@tauri-apps/api/event\";\nimport type { WatchResult } from \"@yaakapp-internal/tauri\";\nimport { SyncOp } from \"./bindings/gen_sync\";\nimport { WatchEvent } from \"./bindings/gen_watch\";\n\nexport * from \"./bindings/gen_models\";\n\nexport async function calculateSync(workspaceId: string, syncDir: string) {\n  return invoke<SyncOp[]>(\"cmd_sync_calculate\", {\n    workspaceId,\n    syncDir,\n  });\n}\n\nexport async function calculateSyncFsOnly(dir: string) {\n  return invoke<SyncOp[]>(\"cmd_sync_calculate_fs\", { dir });\n}\n\nexport async function applySync(workspaceId: string, syncDir: string, syncOps: SyncOp[]) {\n  return invoke<void>(\"cmd_sync_apply\", {\n    workspaceId,\n    syncDir,\n    syncOps: syncOps,\n  });\n}\n\nexport function watchWorkspaceFiles(\n  workspaceId: string,\n  syncDir: string,\n  callback: (e: WatchEvent) => void,\n) {\n  console.log(\"Watching workspace files\", workspaceId, syncDir);\n  const channel = new Channel<WatchEvent>();\n  channel.onmessage = callback;\n  const unlistenPromise = invoke<WatchResult>(\"cmd_sync_watch\", {\n    workspaceId,\n    syncDir,\n    channel,\n  });\n\n  void unlistenPromise.then(({ unlistenEvent }) => {\n    addWatchKey(unlistenEvent);\n  });\n\n  return () =>\n    unlistenPromise\n      .then(async ({ unlistenEvent }) => {\n        console.log(\"Unwatching workspace files\", workspaceId, syncDir);\n        unlistenToWatcher(unlistenEvent);\n      })\n      .catch(console.error);\n}\n\nfunction unlistenToWatcher(unlistenEvent: string) {\n  void emit(unlistenEvent).then(() => {\n    removeWatchKey(unlistenEvent);\n  });\n}\n\nfunction getWatchKeys() {\n  return sessionStorage.getItem(\"workspace-file-watchers\")?.split(\",\").filter(Boolean) ?? [];\n}\n\nfunction setWatchKeys(keys: string[]) {\n  sessionStorage.setItem(\"workspace-file-watchers\", keys.join(\",\"));\n}\n\nfunction addWatchKey(key: string) {\n  const keys = getWatchKeys();\n  setWatchKeys([...keys, key]);\n}\n\nfunction removeWatchKey(key: string) {\n  const keys = getWatchKeys();\n  setWatchKeys(keys.filter((k) => k !== key));\n}\n\n// On page load, unlisten to all zombie watchers\nconst keys = getWatchKeys();\nif (keys.length > 0) {\n  console.log(\"Unsubscribing to zombie file watchers\", keys);\n  keys.forEach(unlistenToWatcher);\n}\n"
  },
  {
    "path": "crates/yaak-sync/package.json",
    "content": "{\n  \"name\": \"@yaakapp-internal/sync\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"index.ts\"\n}\n"
  },
  {
    "path": "crates/yaak-sync/src/error.rs",
    "content": "use serde::{Serialize, Serializer};\nuse std::io;\nuse thiserror::Error;\n\n#[derive(Error, Debug)]\npub enum Error {\n    #[error(\"Yaml error: {0}\")]\n    YamlParseError(#[from] serde_yaml::Error),\n\n    #[error(\"Sync parse error: {0}\")]\n    ParseError(String),\n\n    #[error(transparent)]\n    ModelError(#[from] yaak_models::error::Error),\n\n    #[error(\"Unknown model: {0}\")]\n    UnknownModel(String),\n\n    #[error(\"I/o error: {0}\")]\n    IoError(#[from] io::Error),\n\n    #[error(\"JSON error: {0}\")]\n    JsonParseError(#[from] serde_json::Error),\n\n    #[error(\"Invalid sync file: {0}\")]\n    InvalidSyncFile(String),\n\n    #[error(\"Invalid sync directory: {0}\")]\n    InvalidSyncDirectory(String),\n\n    #[error(\"Watch error: {0}\")]\n    NotifyError(#[from] notify::Error),\n}\n\nimpl Serialize for Error {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        serializer.serialize_str(self.to_string().as_ref())\n    }\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates/yaak-sync/src/lib.rs",
    "content": "pub mod error;\npub mod models;\npub mod sync;\npub mod watch;\n"
  },
  {
    "path": "crates/yaak-sync/src/models.rs",
    "content": "use crate::error::Error::UnknownModel;\nuse crate::error::Result;\nuse chrono::NaiveDateTime;\nuse log::{debug, warn};\nuse serde::{Deserialize, Deserializer, Serialize};\nuse serde_yaml::{Mapping, Value};\nuse sha1::{Digest, Sha1};\nuse std::fs;\nuse std::path::Path;\nuse ts_rs::TS;\nuse yaak_models::models::{\n    AnyModel, Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,\n};\n\n#[derive(Debug, Clone, PartialEq, Serialize, TS)]\n#[serde(rename_all = \"snake_case\", tag = \"type\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub enum SyncModel {\n    Workspace(Workspace),\n    Environment(Environment),\n    Folder(Folder),\n    HttpRequest(HttpRequest),\n    GrpcRequest(GrpcRequest),\n    WebsocketRequest(WebsocketRequest),\n}\n\nimpl<'de> Deserialize<'de> for SyncModel {\n    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>\n    where\n        D: Deserializer<'de>,\n    {\n        use serde_path_to_error as spte;\n        let mut v = Value::deserialize(deserializer)?;\n        let model = match v.get(\"model\") {\n            Some(Value::String(model)) => model.clone(),\n            _ => \"\".to_string(),\n        };\n        let model = model.as_str();\n\n        let obj = v\n            .as_mapping_mut()\n            .ok_or_else(|| serde::de::Error::custom(\"expected object for SyncModel\"))?;\n\n        // Dispatch to CHILD types (no recursion)\n        match model {\n            \"workspace\" => {\n                let x: Workspace = spte::deserialize(v).map_err(serde::de::Error::custom)?;\n                Ok(SyncModel::Workspace(x))\n            }\n            \"environment\" => {\n                migrate_environment(obj);\n                let x: Environment = spte::deserialize(v).map_err(serde::de::Error::custom)?;\n                Ok(SyncModel::Environment(x))\n            }\n            \"folder\" => {\n                let x: Folder = spte::deserialize(v).map_err(serde::de::Error::custom)?;\n                Ok(SyncModel::Folder(x))\n            }\n            \"http_request\" => {\n                let x: HttpRequest = spte::deserialize(v).map_err(serde::de::Error::custom)?;\n                Ok(SyncModel::HttpRequest(x))\n            }\n            \"grpc_request\" => {\n                let x: GrpcRequest = spte::deserialize(v).map_err(serde::de::Error::custom)?;\n                Ok(SyncModel::GrpcRequest(x))\n            }\n            \"websocket_request\" => {\n                let x: WebsocketRequest = spte::deserialize(v).map_err(serde::de::Error::custom)?;\n                Ok(SyncModel::WebsocketRequest(x))\n            }\n            other => Err(serde::de::Error::unknown_variant(\n                other,\n                &[\n                    \"workspace\",\n                    \"environment\",\n                    \"folder\",\n                    \"http_request\",\n                    \"grpc_request\",\n                    \"websocket_request\",\n                ],\n            )),\n        }\n    }\n}\n\nfn migrate_environment(obj: &mut Mapping) {\n    match (obj.get(\"base\"), obj.get(\"parentModel\")) {\n        (Some(Value::Bool(base)), None) => {\n            debug!(\"Migrating legacy environment {:?}\", obj.get(\"id\"));\n            if *base {\n                obj.insert(\"parentModel\".into(), \"workspace\".into());\n            } else {\n                obj.insert(\"parentModel\".into(), \"environment\".into());\n            }\n        }\n        _ => {}\n    }\n}\n\nimpl SyncModel {\n    pub fn from_bytes(content: Vec<u8>, file_path: &Path) -> Result<Option<(SyncModel, String)>> {\n        let mut hasher = Sha1::new();\n        hasher.update(&content);\n        let checksum = hex::encode(hasher.finalize());\n        let content_str = String::from_utf8(content.clone()).unwrap_or_default();\n\n        // Check for some strings that will be in a model file for sure. If these strings\n        // don't exist, then it's probably not a Yaak file.\n        if !content_str.contains(\"model\") || !content_str.contains(\"id\") {\n            return Ok(None);\n        }\n\n        let ext = file_path.extension().unwrap_or_default();\n        if ext == \"yml\" || ext == \"yaml\" {\n            Ok(match serde_yaml::from_str::<SyncModel>(&content_str) {\n                Ok(m) => Some((m, checksum)),\n                Err(e) => {\n                    warn!(\"Error parsing {:?} {:?}\", file_path.file_name(), e);\n                    None\n                }\n            })\n        } else if ext == \"json\" {\n            Ok(match serde_json::from_str::<SyncModel>(&content_str) {\n                Ok(m) => Some((m, checksum)),\n                Err(e) => {\n                    warn!(\"Error parsing {:?} {:?}\", file_path.file_name(), e);\n                    None\n                }\n            })\n        } else {\n            Ok(None)\n        }\n    }\n\n    pub fn from_file(file_path: &Path) -> Result<Option<(SyncModel, String)>> {\n        let content = match fs::read(file_path) {\n            Ok(c) => c,\n            Err(_) => return Ok(None),\n        };\n\n        Self::from_bytes(content, file_path)\n    }\n\n    pub fn to_file_contents(&self, rel_path: &Path) -> Result<(Vec<u8>, String)> {\n        let ext = rel_path.extension().unwrap_or_default();\n        let content = if ext == \"yaml\" || ext == \"yml\" {\n            serde_yaml::to_string(self)?\n        } else {\n            serde_json::to_string(self)?\n        };\n\n        let mut hasher = Sha1::new();\n        hasher.update(&content);\n        let checksum = hex::encode(hasher.finalize());\n\n        Ok((content.into_bytes(), checksum))\n    }\n\n    pub fn id(&self) -> String {\n        match self.clone() {\n            SyncModel::Workspace(m) => m.id,\n            SyncModel::Environment(m) => m.id,\n            SyncModel::Folder(m) => m.id,\n            SyncModel::HttpRequest(m) => m.id,\n            SyncModel::GrpcRequest(m) => m.id,\n            SyncModel::WebsocketRequest(m) => m.id,\n        }\n    }\n\n    pub fn workspace_id(&self) -> String {\n        match self.clone() {\n            SyncModel::Workspace(m) => m.id,\n            SyncModel::Environment(m) => m.workspace_id,\n            SyncModel::Folder(m) => m.workspace_id,\n            SyncModel::HttpRequest(m) => m.workspace_id,\n            SyncModel::GrpcRequest(m) => m.workspace_id,\n            SyncModel::WebsocketRequest(m) => m.workspace_id,\n        }\n    }\n\n    pub fn updated_at(&self) -> NaiveDateTime {\n        match self.clone() {\n            SyncModel::Workspace(m) => m.updated_at,\n            SyncModel::Environment(m) => m.updated_at,\n            SyncModel::Folder(m) => m.updated_at,\n            SyncModel::HttpRequest(m) => m.updated_at,\n            SyncModel::GrpcRequest(m) => m.updated_at,\n            SyncModel::WebsocketRequest(m) => m.updated_at,\n        }\n    }\n}\n\nimpl TryFrom<AnyModel> for SyncModel {\n    type Error = crate::error::Error;\n\n    fn try_from(value: AnyModel) -> Result<Self> {\n        let m = match value {\n            AnyModel::Environment(m) => SyncModel::Environment(m),\n            AnyModel::Folder(m) => SyncModel::Folder(m),\n            AnyModel::GrpcRequest(m) => SyncModel::GrpcRequest(m),\n            AnyModel::HttpRequest(m) => SyncModel::HttpRequest(m),\n            AnyModel::WebsocketRequest(m) => SyncModel::WebsocketRequest(m),\n            AnyModel::Workspace(m) => SyncModel::Workspace(m),\n\n            // Non-sync models\n            AnyModel::CookieJar(m) => return Err(UnknownModel(m.model)),\n            AnyModel::GraphQlIntrospection(m) => return Err(UnknownModel(m.model)),\n            AnyModel::GrpcConnection(m) => return Err(UnknownModel(m.model)),\n            AnyModel::GrpcEvent(m) => return Err(UnknownModel(m.model)),\n            AnyModel::HttpResponse(m) => return Err(UnknownModel(m.model)),\n            AnyModel::HttpResponseEvent(m) => return Err(UnknownModel(m.model)),\n            AnyModel::KeyValue(m) => return Err(UnknownModel(m.model)),\n            AnyModel::Plugin(m) => return Err(UnknownModel(m.model)),\n            AnyModel::Settings(m) => return Err(UnknownModel(m.model)),\n            AnyModel::WebsocketConnection(m) => return Err(UnknownModel(m.model)),\n            AnyModel::WebsocketEvent(m) => return Err(UnknownModel(m.model)),\n            AnyModel::WorkspaceMeta(m) => return Err(UnknownModel(m.model)),\n            AnyModel::SyncState(m) => return Err(UnknownModel(m.model)),\n        };\n        Ok(m)\n    }\n}\n\n#[cfg(test)]\nmod migration_tests {\n    use crate::error::Result;\n    use crate::models::SyncModel;\n\n    #[test]\n    fn deserializes_environment_via_syncmodel_with_fixups() -> Result<()> {\n        let raw = r#\"\ntype: environment\nmodel: environment\nid: ev_fAUS49FUN2\nworkspaceId: wk_kfSI3JDHd7\ncreatedAt: 2025-01-11T17:02:58.012792\nupdatedAt: 2025-07-23T20:00:46.049649\nname: Global Variables\npublic: true\nbase: true\nvariables: []\ncolor: null\n\"#;\n\n        let m: SyncModel = serde_yaml::from_str(raw)?;\n        match m {\n            SyncModel::Environment(env) => {\n                assert_eq!(env.parent_model, \"workspace\".to_string());\n                assert_eq!(env.parent_id, None);\n            }\n            _ => panic!(\"expected base environment\"),\n        }\n\n        let raw = r#\"\ntype: environment\nmodel: environment\nid: ev_fAUS49FUN2\nworkspaceId: wk_kfSI3JDHd7\ncreatedAt: 2025-01-11T17:02:58.012792\nupdatedAt: 2025-07-23T20:00:46.049649\nname: Global Variables\npublic: true\nbase: false\nvariables: []\ncolor: null\n\"#;\n        let m: SyncModel = serde_yaml::from_str(raw)?;\n        match m {\n            SyncModel::Environment(env) => {\n                assert_eq!(env.parent_model, \"environment\".to_string());\n                assert_eq!(env.parent_id, None);\n            }\n            _ => panic!(\"expected sub environment\"),\n        }\n\n        let raw = r#\"\ntype: environment\nmodel: environment\nid: ev_fAUS49FUN2\nparentId: fld_123\nparentModel: folder\nworkspaceId: wk_kfSI3JDHd7\ncreatedAt: 2025-01-11T17:02:58.012792\nupdatedAt: 2025-07-23T20:00:46.049649\nname: Folder Environment\npublic: true\nbase: false\nvariables: []\ncolor: null\n\"#;\n        let m: SyncModel = serde_yaml::from_str(raw)?;\n        match m {\n            SyncModel::Environment(env) => {\n                assert_eq!(env.parent_model, \"folder\".to_string());\n                assert_eq!(env.parent_id, Some(\"fld_123\".to_string()));\n            }\n            _ => panic!(\"expected folder environment\"),\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/yaak-sync/src/sync.rs",
    "content": "use crate::error::Result;\nuse crate::models::SyncModel;\nuse chrono::Utc;\nuse log::{info, warn};\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse std::fmt::{Display, Formatter};\nuse std::fs;\nuse std::fs::File;\nuse std::io::Write;\nuse std::path::{Path, PathBuf};\nuse ts_rs::TS;\nuse yaak_models::db_context::DbContext;\nuse yaak_models::models::{SyncState, WorkspaceMeta};\nuse yaak_models::util::{UpdateSource, get_workspace_export_resources};\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\", tag = \"type\")]\n#[ts(export, export_to = \"gen_sync.ts\")]\npub enum SyncOp {\n    FsCreate {\n        model: SyncModel,\n    },\n    FsUpdate {\n        model: SyncModel,\n        state: SyncState,\n    },\n    FsDelete {\n        state: SyncState,\n        fs: Option<FsCandidate>,\n    },\n    DbCreate {\n        fs: FsCandidate,\n    },\n    DbUpdate {\n        state: SyncState,\n        fs: FsCandidate,\n    },\n    DbDelete {\n        model: SyncModel,\n        state: SyncState,\n    },\n    IgnorePrivate {\n        model: SyncModel,\n    },\n}\n\nimpl SyncOp {\n    fn workspace_id(&self) -> String {\n        match self {\n            SyncOp::DbCreate { fs } => fs.model.workspace_id(),\n            SyncOp::DbDelete { model, .. } => model.workspace_id(),\n            SyncOp::DbUpdate { state, .. } => state.workspace_id.clone(),\n            SyncOp::FsCreate { model } => model.workspace_id(),\n            SyncOp::FsDelete { state, .. } => state.workspace_id.clone(),\n            SyncOp::FsUpdate { state, .. } => state.workspace_id.clone(),\n            SyncOp::IgnorePrivate { model } => model.workspace_id(),\n        }\n    }\n}\n\nimpl Display for SyncOp {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        f.write_str(\n            match self {\n                SyncOp::FsCreate { model } => format!(\"fs_create({})\", model.id()),\n                SyncOp::FsUpdate { model, .. } => format!(\"fs_update({})\", model.id()),\n                SyncOp::FsDelete { state, .. } => format!(\"fs_delete({})\", state.model_id),\n                SyncOp::DbCreate { fs } => format!(\"db_create({})\", fs.model.id()),\n                SyncOp::DbUpdate { fs, .. } => format!(\"db_update({})\", fs.model.id()),\n                SyncOp::DbDelete { model, .. } => format!(\"db_delete({})\", model.id()),\n                SyncOp::IgnorePrivate { model } => format!(\"ignore_private({})\", model.id()),\n            }\n            .as_str(),\n        )\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum DbCandidate {\n    Added(SyncModel),\n    Deleted(SyncState),\n    Modified(SyncModel, SyncState),\n    Unmodified(SyncModel, SyncState),\n}\n\nimpl DbCandidate {\n    fn model_id(&self) -> String {\n        match &self {\n            DbCandidate::Added(m) => m.id(),\n            DbCandidate::Deleted(s) => s.model_id.clone(),\n            DbCandidate::Modified(m, _) => m.id(),\n            DbCandidate::Unmodified(m, _) => m.id(),\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\", tag = \"type\")]\n#[ts(export, export_to = \"gen_sync.ts\")]\npub struct FsCandidate {\n    pub model: SyncModel,\n    pub rel_path: PathBuf,\n    pub checksum: String,\n}\n\npub fn get_db_candidates(\n    db: &DbContext,\n    version: &str,\n    workspace_id: &str,\n    sync_dir: &Path,\n) -> Result<Vec<DbCandidate>> {\n    let models: HashMap<_, _> =\n        workspace_models(db, version, workspace_id)?.into_iter().map(|m| (m.id(), m)).collect();\n    let sync_states: HashMap<_, _> = db\n        .list_sync_states_for_workspace(workspace_id, sync_dir)?\n        .into_iter()\n        .map(|s| (s.model_id.clone(), s))\n        .collect();\n\n    // 1. Add candidates for models (created/modified/unmodified)\n    let mut candidates: Vec<DbCandidate> = models\n        .values()\n        .filter_map(|model| {\n            match sync_states.get(&model.id()) {\n                Some(existing_sync_state) => {\n                    // If a sync state exists but the model is now private, treat it as a deletion\n                    match model {\n                        SyncModel::Environment(e) if !e.public => {\n                            return Some(DbCandidate::Deleted(existing_sync_state.to_owned()));\n                        }\n                        _ => {}\n                    };\n\n                    let updated_since_flush = model.updated_at() > existing_sync_state.flushed_at;\n                    if updated_since_flush {\n                        Some(DbCandidate::Modified(\n                            model.to_owned(),\n                            existing_sync_state.to_owned(),\n                        ))\n                    } else {\n                        Some(DbCandidate::Unmodified(\n                            model.to_owned(),\n                            existing_sync_state.to_owned(),\n                        ))\n                    }\n                }\n                None => {\n                    return match model {\n                        SyncModel::Environment(e) if !e.public => {\n                            // No sync state yet, so ignore the model\n                            None\n                        }\n                        _ => {\n                            // No sync state yet, so the model was just added\n                            Some(DbCandidate::Added(model.to_owned()))\n                        }\n                    };\n                }\n            }\n        })\n        .collect();\n\n    // 2. Add SyncState-only candidates (deleted)\n    candidates.extend(sync_states.values().filter_map(|sync_state| {\n        if models.contains_key(&sync_state.model_id) {\n            None\n        } else {\n            Some(DbCandidate::Deleted(sync_state.to_owned()))\n        }\n    }));\n\n    Ok(candidates)\n}\n\npub fn get_fs_candidates(dir: &Path) -> Result<Vec<FsCandidate>> {\n    // Ensure the root directory exists\n    fs::create_dir_all(dir)?;\n\n    let mut candidates = Vec::new();\n    let entries = fs::read_dir(dir)?;\n    for dir_entry in entries {\n        let dir_entry = match dir_entry {\n            Ok(v) => v,\n            Err(_) => continue,\n        };\n\n        if !dir_entry.file_type()?.is_file() {\n            continue;\n        };\n\n        let path = dir_entry.path();\n        let (model, checksum) = match SyncModel::from_file(&path) {\n            Ok(Some(m)) => m,\n            Ok(None) => continue,\n            Err(e) => {\n                warn!(\"Failed to parse sync file {e}\");\n                return Err(e);\n            }\n        };\n\n        let rel_path = Path::new(&dir_entry.file_name()).to_path_buf();\n        candidates.push(FsCandidate { rel_path, model, checksum })\n    }\n\n    Ok(candidates)\n}\n\npub fn compute_sync_ops(\n    db_candidates: Vec<DbCandidate>,\n    fs_candidates: Vec<FsCandidate>,\n) -> Vec<SyncOp> {\n    let mut db_map: HashMap<String, DbCandidate> = HashMap::new();\n    for c in db_candidates {\n        db_map.insert(c.model_id(), c);\n    }\n\n    let mut fs_map: HashMap<String, FsCandidate> = HashMap::new();\n    for c in fs_candidates {\n        fs_map.insert(c.model.id(), c);\n    }\n\n    // Collect all keys from both maps for the OUTER JOIN\n    let keys: std::collections::HashSet<_> = db_map.keys().chain(fs_map.keys()).collect();\n\n    keys.into_iter()\n        .filter_map(|k| {\n            let op = match (db_map.get(k), fs_map.get(k)) {\n                (None, None) => return None, // Can never happen\n                (None, Some(fs)) => SyncOp::DbCreate { fs: fs.to_owned() },\n\n                // DB unchanged <-> FS missing\n                (Some(DbCandidate::Unmodified(model, sync_state)), None) => {\n                    SyncOp::DbDelete { model: model.to_owned(), state: sync_state.to_owned() }\n                }\n\n                // DB modified <-> FS missing\n                (Some(DbCandidate::Modified(model, sync_state)), None) => {\n                    SyncOp::FsUpdate { model: model.to_owned(), state: sync_state.to_owned() }\n                }\n\n                // DB added <-> FS missing\n                (Some(DbCandidate::Added(model)), None) => {\n                    SyncOp::FsCreate { model: model.to_owned() }\n                }\n\n                // DB deleted <-> FS missing\n                //   Already deleted on FS, but sending it so the SyncState gets dealt with\n                (Some(DbCandidate::Deleted(sync_state)), None) => {\n                    SyncOp::FsDelete { state: sync_state.to_owned(), fs: None }\n                }\n\n                // DB unchanged <-> FS exists\n                (Some(DbCandidate::Unmodified(_, sync_state)), Some(fs_candidate)) => {\n                    if sync_state.checksum == fs_candidate.checksum {\n                        return None;\n                    } else {\n                        SyncOp::DbUpdate {\n                            state: sync_state.to_owned(),\n                            fs: fs_candidate.to_owned(),\n                        }\n                    }\n                }\n\n                // DB modified <-> FS exists\n                (Some(DbCandidate::Modified(model, sync_state)), Some(fs_candidate)) => {\n                    if sync_state.checksum == fs_candidate.checksum {\n                        SyncOp::FsUpdate { model: model.to_owned(), state: sync_state.to_owned() }\n                    } else if model.updated_at() < fs_candidate.model.updated_at() {\n                        // CONFLICT! Write to DB if the fs model is newer\n                        SyncOp::DbUpdate {\n                            state: sync_state.to_owned(),\n                            fs: fs_candidate.to_owned(),\n                        }\n                    } else {\n                        // CONFLICT! Write to FS if the db model is newer\n                        SyncOp::FsUpdate { model: model.to_owned(), state: sync_state.to_owned() }\n                    }\n                }\n\n                // DB added <-> FS anything\n                (Some(DbCandidate::Added(model)), Some(_)) => {\n                    // This would be super rare (impossible?), so let's follow the user's intention\n                    SyncOp::FsCreate { model: model.to_owned() }\n                }\n\n                // DB deleted <-> FS exists\n                (Some(DbCandidate::Deleted(sync_state)), Some(fs_candidate)) => SyncOp::FsDelete {\n                    state: sync_state.to_owned(),\n                    fs: Some(fs_candidate.to_owned()),\n                },\n            };\n            Some(op)\n        })\n        .collect()\n}\n\nfn workspace_models(db: &DbContext, version: &str, workspace_id: &str) -> Result<Vec<SyncModel>> {\n    // We want to include private environments here so that we can take them into account during\n    // the sync process. Otherwise, they would be treated as deleted.\n    let include_private_environments = true;\n    let resources = get_workspace_export_resources(\n        db,\n        version,\n        vec![workspace_id],\n        include_private_environments,\n    )?\n    .resources;\n    let workspace = resources.workspaces.iter().find(|w| w.id == workspace_id);\n\n    let workspace = match workspace {\n        None => return Ok(Vec::new()),\n        Some(w) => w,\n    };\n\n    let mut sync_models = vec![SyncModel::Workspace(workspace.to_owned())];\n\n    for m in resources.environments {\n        sync_models.push(SyncModel::Environment(m));\n    }\n    for m in resources.folders {\n        sync_models.push(SyncModel::Folder(m));\n    }\n    for m in resources.http_requests {\n        sync_models.push(SyncModel::HttpRequest(m));\n    }\n    for m in resources.grpc_requests {\n        sync_models.push(SyncModel::GrpcRequest(m));\n    }\n    for m in resources.websocket_requests {\n        sync_models.push(SyncModel::WebsocketRequest(m));\n    }\n\n    Ok(sync_models)\n}\n\n/// Apply sync operations to the filesystem and database.\n/// Returns a list of SyncStateOps that should be applied afterward.\npub fn apply_sync_ops(\n    db: &DbContext,\n    workspace_id: &str,\n    sync_dir: &Path,\n    sync_ops: Vec<SyncOp>,\n) -> Result<Vec<SyncStateOp>> {\n    if sync_ops.is_empty() {\n        return Ok(Vec::new());\n    }\n\n    info!(\n        \"Applying sync ops {}\",\n        sync_ops.iter().map(|op| op.to_string()).collect::<Vec<String>>().join(\", \")\n    );\n\n    let mut sync_state_ops = Vec::new();\n    let mut workspaces_to_upsert = Vec::new();\n    let mut environments_to_upsert = Vec::new();\n    let mut folders_to_upsert = Vec::new();\n    let mut http_requests_to_upsert = Vec::new();\n    let mut grpc_requests_to_upsert = Vec::new();\n    let mut websocket_requests_to_upsert = Vec::new();\n\n    for op in sync_ops {\n        // Only apply things if workspace ID matches\n        if op.workspace_id() != workspace_id {\n            continue;\n        }\n\n        sync_state_ops.push(match op {\n            SyncOp::FsCreate { model } => {\n                let rel_path = derive_model_filename(&model);\n                let abs_path = sync_dir.join(rel_path.clone());\n                let (content, checksum) = model.to_file_contents(&rel_path)?;\n                let mut f = File::create(&abs_path)?;\n                f.write_all(&content)?;\n                SyncStateOp::Create { model_id: model.id(), checksum, rel_path }\n            }\n            SyncOp::FsUpdate { model, state } => {\n                // Always write the existing path\n                let rel_path = Path::new(&state.rel_path);\n                let abs_path = Path::new(&state.sync_dir).join(&rel_path);\n                let (content, checksum) = model.to_file_contents(&rel_path)?;\n                let mut f = File::create(&abs_path)?;\n                f.write_all(&content)?;\n                SyncStateOp::Update {\n                    state: state.to_owned(),\n                    checksum,\n                    rel_path: rel_path.to_owned(),\n                }\n            }\n            SyncOp::FsDelete { state, fs: fs_candidate } => match fs_candidate {\n                None => SyncStateOp::Delete { state: state.to_owned() },\n                Some(_) => {\n                    // Always delete the existing path\n                    let rel_path = Path::new(&state.rel_path);\n                    let abs_path = Path::new(&state.sync_dir).join(&rel_path);\n                    fs::remove_file(&abs_path)?;\n                    SyncStateOp::Delete { state: state.to_owned() }\n                }\n            },\n            SyncOp::DbCreate { fs } => {\n                let model_id = fs.model.id();\n\n                // Push updates to arrays so we can do them all in a single\n                // batch upsert to make foreign keys happy\n                match fs.model {\n                    SyncModel::Environment(m) => environments_to_upsert.push(m),\n                    SyncModel::Folder(m) => folders_to_upsert.push(m),\n                    SyncModel::GrpcRequest(m) => grpc_requests_to_upsert.push(m),\n                    SyncModel::HttpRequest(m) => http_requests_to_upsert.push(m),\n                    SyncModel::WebsocketRequest(m) => websocket_requests_to_upsert.push(m),\n                    SyncModel::Workspace(m) => workspaces_to_upsert.push(m),\n                };\n                SyncStateOp::Create {\n                    model_id,\n                    checksum: fs.checksum.to_owned(),\n                    rel_path: fs.rel_path.to_owned(),\n                }\n            }\n            SyncOp::DbUpdate { state, fs } => {\n                // Push updates to arrays so we can do them all in a single\n                // batch upsert to make foreign keys happy\n                match fs.model {\n                    SyncModel::Environment(m) => environments_to_upsert.push(m),\n                    SyncModel::Folder(m) => folders_to_upsert.push(m),\n                    SyncModel::GrpcRequest(m) => grpc_requests_to_upsert.push(m),\n                    SyncModel::HttpRequest(m) => http_requests_to_upsert.push(m),\n                    SyncModel::WebsocketRequest(m) => websocket_requests_to_upsert.push(m),\n                    SyncModel::Workspace(m) => workspaces_to_upsert.push(m),\n                }\n                SyncStateOp::Update {\n                    state: state.to_owned(),\n                    checksum: fs.checksum.to_owned(),\n                    rel_path: fs.rel_path.to_owned(),\n                }\n            }\n            SyncOp::DbDelete { model, state } => {\n                delete_model(db, &model)?;\n                SyncStateOp::Delete { state: state.to_owned() }\n            }\n            SyncOp::IgnorePrivate { .. } => SyncStateOp::NoOp,\n        });\n    }\n\n    let upserted_models = db.batch_upsert(\n        workspaces_to_upsert,\n        environments_to_upsert,\n        folders_to_upsert,\n        http_requests_to_upsert,\n        grpc_requests_to_upsert,\n        websocket_requests_to_upsert,\n        &UpdateSource::Sync,\n    )?;\n\n    // Ensure we create WorkspaceMeta models for each new workspace, with the appropriate sync dir\n    let sync_dir_string = sync_dir.to_string_lossy().to_string();\n    for workspace in upserted_models.workspaces {\n        match db.get_workspace_meta(&workspace.id) {\n            Some(m) => {\n                if m.setting_sync_dir == Some(sync_dir_string.clone()) {\n                    // We don't need to update if unchanged\n                    continue;\n                }\n                db.upsert_workspace_meta(\n                    &WorkspaceMeta {\n                        setting_sync_dir: Some(sync_dir.to_string_lossy().to_string()),\n                        ..m\n                    },\n                    &UpdateSource::Sync,\n                )\n            }\n            None => db.upsert_workspace_meta(\n                &WorkspaceMeta {\n                    workspace_id: workspace_id.to_string(),\n                    setting_sync_dir: Some(sync_dir.to_string_lossy().to_string()),\n                    ..Default::default()\n                },\n                &UpdateSource::Sync,\n            ),\n        }?;\n    }\n\n    Ok(sync_state_ops)\n}\n\n#[derive(Debug)]\npub enum SyncStateOp {\n    Create {\n        model_id: String,\n        checksum: String,\n        rel_path: PathBuf,\n    },\n    Update {\n        state: SyncState,\n        checksum: String,\n        rel_path: PathBuf,\n    },\n    Delete {\n        state: SyncState,\n    },\n    NoOp,\n}\n\npub fn apply_sync_state_ops(\n    db: &DbContext,\n    workspace_id: &str,\n    sync_dir: &Path,\n    ops: Vec<SyncStateOp>,\n) -> Result<()> {\n    for op in ops {\n        match op {\n            SyncStateOp::Create { checksum, rel_path, model_id } => {\n                let sync_state = SyncState {\n                    workspace_id: workspace_id.to_string(),\n                    model_id,\n                    checksum,\n                    sync_dir: sync_dir.to_str().unwrap().to_string(),\n                    rel_path: rel_path.to_str().unwrap().to_string(),\n                    flushed_at: Utc::now().naive_utc(),\n                    ..Default::default()\n                };\n                db.upsert_sync_state(&sync_state)?;\n            }\n            SyncStateOp::Update { state: sync_state, checksum, rel_path } => {\n                let sync_state = SyncState {\n                    checksum,\n                    sync_dir: sync_dir.to_str().unwrap().to_string(),\n                    rel_path: rel_path.to_str().unwrap().to_string(),\n                    flushed_at: Utc::now().naive_utc(),\n                    ..sync_state\n                };\n                db.upsert_sync_state(&sync_state)?;\n            }\n            SyncStateOp::Delete { state } => {\n                db.delete_sync_state(&state)?;\n            }\n            SyncStateOp::NoOp => {\n                // Nothing\n            }\n        }\n    }\n    Ok(())\n}\n\nfn derive_model_filename(m: &SyncModel) -> PathBuf {\n    let rel = format!(\"yaak.{}.yaml\", m.id());\n    Path::new(&rel).to_path_buf()\n}\n\nfn delete_model(db: &DbContext, model: &SyncModel) -> Result<()> {\n    match model {\n        SyncModel::Workspace(m) => {\n            db.delete_workspace(&m, &UpdateSource::Sync)?;\n        }\n        SyncModel::Environment(m) => {\n            db.delete_environment(&m, &UpdateSource::Sync)?;\n        }\n        SyncModel::Folder(m) => {\n            db.delete_folder(&m, &UpdateSource::Sync)?;\n        }\n        SyncModel::HttpRequest(m) => {\n            db.delete_http_request(&m, &UpdateSource::Sync)?;\n        }\n        SyncModel::GrpcRequest(m) => {\n            db.delete_grpc_request(&m, &UpdateSource::Sync)?;\n        }\n        SyncModel::WebsocketRequest(m) => {\n            db.delete_websocket_request(&m, &UpdateSource::Sync)?;\n        }\n    };\n    Ok(())\n}\n"
  },
  {
    "path": "crates/yaak-sync/src/watch.rs",
    "content": "use crate::error::Result;\nuse log::{error, info};\nuse notify::Watcher;\nuse serde::{Deserialize, Serialize};\nuse std::path::{Path, PathBuf};\nuse std::sync::mpsc;\nuse tokio::select;\nuse ts_rs::TS;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_watch.ts\")]\npub struct WatchEvent {\n    pub paths: Vec<PathBuf>,\n    pub kind: String,\n}\n\n/// Watch a directory for changes and send events through a callback.\n///\n/// The callback is invoked for each watch event. The function returns when\n/// the cancel receiver receives a signal.\npub async fn watch_directory<F>(\n    dir: &Path,\n    callback: F,\n    mut cancel_rx: tokio::sync::watch::Receiver<()>,\n) -> Result<()>\nwhere\n    F: Fn(WatchEvent) + Send + 'static,\n{\n    let dir = dir.to_owned();\n    let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();\n    let mut watcher = notify::recommended_watcher(tx)?;\n\n    // Spawn a blocking thread to handle the blocking `std::sync::mpsc::Receiver`\n    let (async_tx, mut async_rx) = tokio::sync::mpsc::channel::<notify::Result<notify::Event>>(100);\n    std::thread::spawn(move || {\n        for res in rx {\n            if async_tx.blocking_send(res).is_err() {\n                break; // Exit the thread if the async receiver is closed\n            }\n        }\n    });\n\n    tokio::spawn(async move {\n        watcher.watch(&dir, notify::RecursiveMode::Recursive).expect(\"Failed to watch directory\");\n        info!(\"Watching directory {:?}\", dir);\n\n        loop {\n            select! {\n                // Listen for new watch events\n                Some(event_res) = async_rx.recv() => {\n                    match event_res {\n                        Ok(event) => {\n                            // Filter out any ignored directories and see if we still get a result\n                            let paths = event.paths.into_iter()\n                                .map(|p| p.strip_prefix(&dir).unwrap().to_path_buf())\n                                .filter(|p| !p.starts_with(\".git\") && !p.starts_with(\"node_modules\"))\n                                .collect::<Vec<PathBuf>>();\n\n                            if paths.is_empty() {\n                                continue;\n                            }\n\n                            callback(WatchEvent {\n                                paths,\n                                kind: format!(\"{:?}\", event.kind),\n                            });\n                        }\n                        Err(e) => error!(\"Directory watch error: {:?}\", e),\n                    }\n                }\n                // Listen for cancellation\n                _ = cancel_rx.changed() => {\n                    // To cancel, we break from the loop, which will exit the task and make the\n                    // watcher go out of scope (cancelling it)\n                    info!(\"Cancelling watch for {:?}\", dir);\n                    break;\n                }\n            }\n        }\n    });\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/yaak-templates/Cargo.toml",
    "content": "[package]\nname = \"yaak-templates\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[package.metadata.wasm-pack.profile.release]\nwasm-opt = false # Causes errors in CI (haven't figured out why yet)\n\n[lib]\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[dependencies]\nbase64 = \"0.22.1\"\nserde = { workspace = true, features = [\"derive\"] }\nserde_json = { workspace = true }\nthiserror = { workspace = true }\ntokio = { workspace = true, features = [\"macros\", \"rt\"] }\nts-rs = { workspace = true }\nwasm-bindgen = { version = \"0.2.100\", features = [\"serde-serialize\"] }\nserde-wasm-bindgen = \"0.6.5\"\nlog = { workspace = true }\n"
  },
  {
    "path": "crates/yaak-templates/bindings/parser.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type FnArg = { name: string, value: Val, };\n\nexport type Token = { \"type\": \"raw\", text: string, } | { \"type\": \"tag\", val: Val, } | { \"type\": \"eof\" };\n\nexport type Tokens = { tokens: Array<Token>, };\n\nexport type Val = { \"type\": \"str\", text: string, } | { \"type\": \"var\", name: string, } | { \"type\": \"bool\", value: boolean, } | { \"type\": \"fn\", name: string, args: Array<FnArg>, } | { \"type\": \"null\" };\n"
  },
  {
    "path": "crates/yaak-templates/build-wasm.cjs",
    "content": "const { execSync } = require(\"node:child_process\");\nconst fs = require(\"node:fs\");\nconst path = require(\"node:path\");\n\nif (process.env.SKIP_WASM_BUILD === \"1\") {\n  console.log(\"Skipping wasm-pack build (SKIP_WASM_BUILD=1)\");\n  return;\n}\n\nexecSync(\"wasm-pack build --target bundler\", { stdio: \"inherit\" });\n\n// Rewrite the generated entry to use Vite's ?init import style instead of\n// the ES Module Integration style that wasm-pack generates, which Vite/rolldown\n// does not support in production builds.\nconst entry = path.join(__dirname, \"pkg\", \"yaak_templates.js\");\nfs.writeFileSync(\n  entry,\n  [\n    'import init from \"./yaak_templates_bg.wasm?init\";',\n    'export * from \"./yaak_templates_bg.js\";',\n    'import * as bg from \"./yaak_templates_bg.js\";',\n    'const instance = await init({ \"./yaak_templates_bg.js\": bg });',\n    \"bg.__wbg_set_wasm(instance.exports);\",\n    \"instance.exports.__wbindgen_start();\",\n    \"\",\n  ].join(\"\\n\"),\n);\n"
  },
  {
    "path": "crates/yaak-templates/index.ts",
    "content": "export * from \"./bindings/parser\";\nimport { Tokens } from \"./bindings/parser\";\nimport { escape_template, parse_template, unescape_template } from \"./pkg\";\n\nexport function parseTemplate(template: string) {\n  return parse_template(template) as Tokens;\n}\n\nexport function escapeTemplate(template: string) {\n  return escape_template(template) as string;\n}\n\nexport function unescapeTemplate(template: string) {\n  return unescape_template(template) as string;\n}\n"
  },
  {
    "path": "crates/yaak-templates/package.json",
    "content": "{\n  \"name\": \"@yaakapp-internal/templates\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"index.ts\",\n  \"scripts\": {\n    \"bootstrap\": \"npm run build\",\n    \"build\": \"run-s build:*\",\n    \"build:pack\": \"node build-wasm.cjs\",\n    \"build:clean\": \"rimraf ./pkg/.gitignore\"\n  },\n  \"devDependencies\": {\n    \"rimraf\": \"^6.1.2\"\n  }\n}\n"
  },
  {
    "path": "crates/yaak-templates/pkg/package.json",
    "content": "{\n  \"name\": \"yaak-templates\",\n  \"type\": \"module\",\n  \"version\": \"0.1.0\",\n  \"files\": [\n    \"yaak_templates_bg.wasm\",\n    \"yaak_templates.js\",\n    \"yaak_templates_bg.js\",\n    \"yaak_templates.d.ts\"\n  ],\n  \"main\": \"yaak_templates.js\",\n  \"types\": \"yaak_templates.d.ts\",\n  \"sideEffects\": [\n    \"./yaak_templates.js\",\n    \"./snippets/*\"\n  ]\n}"
  },
  {
    "path": "crates/yaak-templates/pkg/yaak_templates.d.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\nexport function escape_template(template: string): any;\nexport function parse_template(template: string): any;\nexport function unescape_template(template: string): any;\n"
  },
  {
    "path": "crates/yaak-templates/pkg/yaak_templates.js",
    "content": "import init from \"./yaak_templates_bg.wasm?init\";\nexport * from \"./yaak_templates_bg.js\";\nimport * as bg from \"./yaak_templates_bg.js\";\nconst instance = await init({ \"./yaak_templates_bg.js\": bg });\nbg.__wbg_set_wasm(instance.exports);\ninstance.exports.__wbindgen_start();\n"
  },
  {
    "path": "crates/yaak-templates/pkg/yaak_templates_bg.js",
    "content": "let wasm;\nexport function __wbg_set_wasm(val) {\n    wasm = val;\n}\n\n\nfunction debugString(val) {\n    // primitive types\n    const type = typeof val;\n    if (type == 'number' || type == 'boolean' || val == null) {\n        return  `${val}`;\n    }\n    if (type == 'string') {\n        return `\"${val}\"`;\n    }\n    if (type == 'symbol') {\n        const description = val.description;\n        if (description == null) {\n            return 'Symbol';\n        } else {\n            return `Symbol(${description})`;\n        }\n    }\n    if (type == 'function') {\n        const name = val.name;\n        if (typeof name == 'string' && name.length > 0) {\n            return `Function(${name})`;\n        } else {\n            return 'Function';\n        }\n    }\n    // objects\n    if (Array.isArray(val)) {\n        const length = val.length;\n        let debug = '[';\n        if (length > 0) {\n            debug += debugString(val[0]);\n        }\n        for(let i = 1; i < length; i++) {\n            debug += ', ' + debugString(val[i]);\n        }\n        debug += ']';\n        return debug;\n    }\n    // Test for built-in\n    const builtInMatches = /\\[object ([^\\]]+)\\]/.exec(toString.call(val));\n    let className;\n    if (builtInMatches && builtInMatches.length > 1) {\n        className = builtInMatches[1];\n    } else {\n        // Failed to match the standard '[object ClassName]'\n        return toString.call(val);\n    }\n    if (className == 'Object') {\n        // we're a user defined class or Object\n        // JSON.stringify avoids problems with cycles, and is generally much\n        // easier than looping through ownProperties of `val`.\n        try {\n            return 'Object(' + JSON.stringify(val) + ')';\n        } catch (_) {\n            return 'Object';\n        }\n    }\n    // errors\n    if (val instanceof Error) {\n        return `${val.name}: ${val.message}\\n${val.stack}`;\n    }\n    // TODO we could test for more things here, like `Set`s and `Map`s.\n    return className;\n}\n\nlet WASM_VECTOR_LEN = 0;\n\nlet cachedUint8ArrayMemory0 = null;\n\nfunction getUint8ArrayMemory0() {\n    if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {\n        cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);\n    }\n    return cachedUint8ArrayMemory0;\n}\n\nconst lTextEncoder = typeof TextEncoder === 'undefined' ? (0, module.require)('util').TextEncoder : TextEncoder;\n\nlet cachedTextEncoder = new lTextEncoder('utf-8');\n\nconst encodeString = (typeof cachedTextEncoder.encodeInto === 'function'\n    ? function (arg, view) {\n    return cachedTextEncoder.encodeInto(arg, view);\n}\n    : function (arg, view) {\n    const buf = cachedTextEncoder.encode(arg);\n    view.set(buf);\n    return {\n        read: arg.length,\n        written: buf.length\n    };\n});\n\nfunction passStringToWasm0(arg, malloc, realloc) {\n\n    if (realloc === undefined) {\n        const buf = cachedTextEncoder.encode(arg);\n        const ptr = malloc(buf.length, 1) >>> 0;\n        getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);\n        WASM_VECTOR_LEN = buf.length;\n        return ptr;\n    }\n\n    let len = arg.length;\n    let ptr = malloc(len, 1) >>> 0;\n\n    const mem = getUint8ArrayMemory0();\n\n    let offset = 0;\n\n    for (; offset < len; offset++) {\n        const code = arg.charCodeAt(offset);\n        if (code > 0x7F) break;\n        mem[ptr + offset] = code;\n    }\n\n    if (offset !== len) {\n        if (offset !== 0) {\n            arg = arg.slice(offset);\n        }\n        ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;\n        const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);\n        const ret = encodeString(arg, view);\n\n        offset += ret.written;\n        ptr = realloc(ptr, len, offset, 1) >>> 0;\n    }\n\n    WASM_VECTOR_LEN = offset;\n    return ptr;\n}\n\nlet cachedDataViewMemory0 = null;\n\nfunction getDataViewMemory0() {\n    if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {\n        cachedDataViewMemory0 = new DataView(wasm.memory.buffer);\n    }\n    return cachedDataViewMemory0;\n}\n\nconst lTextDecoder = typeof TextDecoder === 'undefined' ? (0, module.require)('util').TextDecoder : TextDecoder;\n\nlet cachedTextDecoder = new lTextDecoder('utf-8', { ignoreBOM: true, fatal: true });\n\ncachedTextDecoder.decode();\n\nfunction getStringFromWasm0(ptr, len) {\n    ptr = ptr >>> 0;\n    return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));\n}\n\nfunction takeFromExternrefTable0(idx) {\n    const value = wasm.__wbindgen_export_2.get(idx);\n    wasm.__externref_table_dealloc(idx);\n    return value;\n}\n/**\n * @param {string} template\n * @returns {any}\n */\nexport function escape_template(template) {\n    const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);\n    const len0 = WASM_VECTOR_LEN;\n    const ret = wasm.escape_template(ptr0, len0);\n    if (ret[2]) {\n        throw takeFromExternrefTable0(ret[1]);\n    }\n    return takeFromExternrefTable0(ret[0]);\n}\n\n/**\n * @param {string} template\n * @returns {any}\n */\nexport function parse_template(template) {\n    const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);\n    const len0 = WASM_VECTOR_LEN;\n    const ret = wasm.parse_template(ptr0, len0);\n    if (ret[2]) {\n        throw takeFromExternrefTable0(ret[1]);\n    }\n    return takeFromExternrefTable0(ret[0]);\n}\n\n/**\n * @param {string} template\n * @returns {any}\n */\nexport function unescape_template(template) {\n    const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);\n    const len0 = WASM_VECTOR_LEN;\n    const ret = wasm.unescape_template(ptr0, len0);\n    if (ret[2]) {\n        throw takeFromExternrefTable0(ret[1]);\n    }\n    return takeFromExternrefTable0(ret[0]);\n}\n\nexport function __wbg_new_405e22f390576ce2() {\n    const ret = new Object();\n    return ret;\n};\n\nexport function __wbg_new_78feb108b6472713() {\n    const ret = new Array();\n    return ret;\n};\n\nexport function __wbg_set_37837023f3d740e8(arg0, arg1, arg2) {\n    arg0[arg1 >>> 0] = arg2;\n};\n\nexport function __wbg_set_3f1d0b984ed272ed(arg0, arg1, arg2) {\n    arg0[arg1] = arg2;\n};\n\nexport function __wbindgen_debug_string(arg0, arg1) {\n    const ret = debugString(arg1);\n    const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);\n    const len1 = WASM_VECTOR_LEN;\n    getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);\n    getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);\n};\n\nexport function __wbindgen_init_externref_table() {\n    const table = wasm.__wbindgen_export_2;\n    const offset = table.grow(4);\n    table.set(0, undefined);\n    table.set(offset + 0, undefined);\n    table.set(offset + 1, null);\n    table.set(offset + 2, true);\n    table.set(offset + 3, false);\n    ;\n};\n\nexport function __wbindgen_string_new(arg0, arg1) {\n    const ret = getStringFromWasm0(arg0, arg1);\n    return ret;\n};\n\nexport function __wbindgen_throw(arg0, arg1) {\n    throw new Error(getStringFromWasm0(arg0, arg1));\n};\n\n"
  },
  {
    "path": "crates/yaak-templates/pkg/yaak_templates_bg.wasm.d.ts",
    "content": "/* tslint:disable */\n/* eslint-disable */\nexport const memory: WebAssembly.Memory;\nexport const escape_template: (a: number, b: number) => [number, number, number];\nexport const parse_template: (a: number, b: number) => [number, number, number];\nexport const unescape_template: (a: number, b: number) => [number, number, number];\nexport const __wbindgen_malloc: (a: number, b: number) => number;\nexport const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;\nexport const __wbindgen_export_2: WebAssembly.Table;\nexport const __externref_table_dealloc: (a: number) => void;\nexport const __wbindgen_start: () => void;\n"
  },
  {
    "path": "crates/yaak-templates/src/error.rs",
    "content": "use serde::{Serialize, Serializer};\nuse thiserror::Error;\nuse wasm_bindgen::JsValue;\n\n#[derive(Error, Debug, PartialEq)]\npub enum Error {\n    #[error(\"Render Error: {0}\")]\n    RenderError(String),\n\n    #[error(\"Render Error: Variable \\\"{0}\\\" is not defined in active environment\")]\n    VariableNotFound(String),\n\n    #[error(\"Render Error: Max recursion depth exceeded\")]\n    RenderStackExceededError,\n}\n\nimpl Serialize for Error {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        serializer.serialize_str(self.to_string().as_ref())\n    }\n}\n\nimpl Into<JsValue> for Error {\n    fn into(self) -> JsValue {\n        serde_wasm_bindgen::to_value(&self).unwrap()\n    }\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates/yaak-templates/src/escape.rs",
    "content": "pub fn escape_template(text: &str) -> String {\n    let mut result = String::with_capacity(text.len());\n    let chars: Vec<char> = text.chars().collect();\n    let mut i = 0;\n\n    while i < chars.len() {\n        // Check if we're at \"${[\"\n        if i + 2 < chars.len() && chars[i] == '$' && chars[i + 1] == '{' && chars[i + 2] == '[' {\n            // Count preceding backslashes\n            let mut backslash_count = 0;\n            let mut j = i;\n            while j > 0 && chars[j - 1] == '\\\\' {\n                backslash_count += 1;\n                j -= 1;\n            }\n\n            // If odd number of backslashes, the $ is escaped\n            // If even number (including 0), the $ is not escaped\n            let already_escaped = backslash_count % 2 == 1;\n\n            if already_escaped {\n                // Already escaped, just add the current character\n                result.push(chars[i]);\n            } else {\n                // Not escaped, add backslash before $\n                result.push('\\\\');\n                result.push(chars[i]);\n            }\n        } else {\n            result.push(chars[i]);\n        }\n        i += 1;\n    }\n    result\n}\n\npub fn unescape_template(text: &str) -> String {\n    let mut result = String::with_capacity(text.len());\n    let chars: Vec<char> = text.chars().collect();\n    let mut i = 0;\n\n    while i < chars.len() {\n        // Check if we're at \"\\${[\"\n        if i + 3 < chars.len()\n            && chars[i] == '\\\\'\n            && chars[i + 1] == '$'\n            && chars[i + 2] == '{'\n            && chars[i + 3] == '['\n        {\n            // Count preceding backslashes (before the current backslash)\n            let mut backslash_count = 0;\n            let mut j = i;\n            while j > 0 && chars[j - 1] == '\\\\' {\n                backslash_count += 1;\n                j -= 1;\n            }\n\n            // If even number of preceding backslashes, this backslash escapes the $\n            // If odd number, this backslash is itself escaped\n            let escapes_dollar = backslash_count % 2 == 0;\n\n            if escapes_dollar {\n                // Skip the backslash, just add the $\n                result.push(chars[i + 1]);\n                i += 1; // Skip the backslash\n            } else {\n                // This backslash is escaped itself, keep it\n                result.push(chars[i]);\n            }\n        } else {\n            result.push(chars[i]);\n        }\n        i += 1;\n    }\n\n    result\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::escape::{escape_template, unescape_template};\n\n    #[test]\n    fn test_escape_simple() {\n        let input = r#\"${[foo]}\"#;\n        let expected = r#\"\\${[foo]}\"#;\n        assert_eq!(escape_template(input), expected);\n    }\n\n    #[test]\n    fn test_already_escaped() {\n        let input = r#\"\\${[bar]}\"#;\n        let expected = r#\"\\${[bar]}\"#;\n        assert_eq!(escape_template(input), expected);\n    }\n\n    #[test]\n    fn test_double_backslash() {\n        let input = r#\"\\\\${[bar]}\"#;\n        let expected = r#\"\\\\\\${[bar]}\"#;\n        assert_eq!(escape_template(input), expected);\n    }\n\n    #[test]\n    fn test_escape_with_surrounding_text() {\n        let input = r#\"text ${[var]} more\"#;\n        let expected = r#\"text \\${[var]} more\"#;\n        assert_eq!(escape_template(input), expected);\n    }\n\n    #[test]\n    fn test_preserve_already_escaped() {\n        let input = r#\"already \\${[escaped]}\"#;\n        let expected = r#\"already \\${[escaped]}\"#;\n        assert_eq!(escape_template(input), expected);\n    }\n\n    #[test]\n    fn test_multiple_occurrences() {\n        let input = r#\"${[one]} and ${[two]}\"#;\n        let expected = r#\"\\${[one]} and \\${[two]}\"#;\n        assert_eq!(escape_template(input), expected);\n    }\n\n    #[test]\n    fn test_mixed_escaped_and_unescaped() {\n        let input = r#\"mixed \\${[esc]} and ${[unesc]}\"#;\n        let expected = r#\"mixed \\${[esc]} and \\${[unesc]}\"#;\n        assert_eq!(escape_template(input), expected);\n    }\n\n    #[test]\n    fn test_unescape_simple() {\n        let input = r#\"\\${[foo]}\"#;\n        let expected = r#\"${[foo]}\"#;\n        assert_eq!(unescape_template(input), expected);\n    }\n\n    #[test]\n    fn test_unescape_with_text() {\n        let input = r#\"text \\${[var]} more\"#;\n        let expected = r#\"text ${[var]} more\"#;\n        assert_eq!(unescape_template(input), expected);\n    }\n\n    #[test]\n    fn test_unescape_multiple() {\n        let input = r#\"\\${[one]} and \\${[two]}\"#;\n        let expected = r#\"${[one]} and ${[two]}\"#;\n        assert_eq!(unescape_template(input), expected);\n    }\n\n    #[test]\n    fn test_unescape_double_backslash() {\n        let input = r#\"\\\\\\${[bar]}\"#;\n        let expected = r#\"\\\\${[bar]}\"#;\n        assert_eq!(unescape_template(input), expected);\n    }\n\n    #[test]\n    fn test_unescape_plain_text() {\n        let input = r#\"${[foo]}\"#;\n        let expected = r#\"${[foo]}\"#;\n        assert_eq!(unescape_template(input), expected);\n    }\n}\n"
  },
  {
    "path": "crates/yaak-templates/src/format_json.rs",
    "content": "enum FormatState {\n    TemplateTag,\n    String,\n    None,\n}\n\n/// Formats JSON that might contain template tags (skipped entirely)\npub fn format_json(text: &str, tab: &str) -> String {\n    let mut chars = text.chars().peekable();\n\n    let mut new_json = \"\".to_string();\n    let mut depth = 0;\n    let mut state = FormatState::None;\n    let mut saw_newline_in_whitespace = false;\n\n    loop {\n        let rest_of_chars = chars.clone();\n        let current_char = match chars.next() {\n            None => break,\n            Some(c) => c,\n        };\n\n        // Handle JSON string states\n        if let FormatState::String = state {\n            match current_char {\n                '\"' => {\n                    state = FormatState::None;\n                    new_json.push(current_char);\n                    continue;\n                }\n                '\\\\' => {\n                    new_json.push(current_char);\n                    if let Some(c) = chars.next() {\n                        new_json.push(c);\n                    }\n                    continue;\n                }\n                _ => {\n                    new_json.push(current_char);\n                    continue;\n                }\n            }\n        }\n        // Close Template tag states\n        if let FormatState::TemplateTag = state {\n            if rest_of_chars.take(2).collect::<String>() == \"]}\" {\n                state = FormatState::None;\n                new_json.push_str(\"]}\");\n                chars.next(); // Skip the second closing bracket\n                continue;\n            } else {\n                new_json.push(current_char);\n                continue;\n            }\n        }\n\n        if rest_of_chars.take(3).collect::<String>() == \"${[\" {\n            state = FormatState::TemplateTag;\n            new_json.push_str(\"${[\");\n            chars.next(); // Skip {\n            chars.next(); // Skip [\n            continue;\n        }\n\n        // Handle line comments (//)\n        if current_char == '/' && chars.peek() == Some(&'/') {\n            chars.next(); // Skip second /\n            // Collect the rest of the comment until newline\n            let mut comment = String::from(\"//\");\n            loop {\n                match chars.peek() {\n                    Some(&'\\n') | None => break,\n                    Some(_) => comment.push(chars.next().unwrap()),\n                }\n            }\n            // Check if the comma handler already added \\n + indent\n            let trimmed = new_json.trim_end_matches(|c: char| c == ' ' || c == '\\t');\n            if trimmed.ends_with(\",\\n\") && !saw_newline_in_whitespace {\n                // Trailing comment on the same line as comma (e.g. \"foo\",// comment)\n                new_json.truncate(trimmed.len() - 1);\n                new_json.push(' ');\n            } else if !trimmed.ends_with('\\n') && !new_json.is_empty() {\n                // Trailing comment after a value (no newline before us)\n                new_json.push(' ');\n            }\n            new_json.push_str(&comment);\n            new_json.push('\\n');\n            new_json.push_str(tab.to_string().repeat(depth).as_str());\n            saw_newline_in_whitespace = false;\n            continue;\n        }\n\n        // Handle block comments (/* ... */)\n        if current_char == '/' && chars.peek() == Some(&'*') {\n            chars.next(); // Skip *\n            let mut comment = String::from(\"/*\");\n            loop {\n                match chars.next() {\n                    None => break,\n                    Some('*') if chars.peek() == Some(&'/') => {\n                        chars.next(); // Skip /\n                        comment.push_str(\"*/\");\n                        break;\n                    }\n                    Some(c) => comment.push(c),\n                }\n            }\n            // If we're not already on a fresh line, add newline + indent before comment\n            let trimmed = new_json.trim_end_matches(|c: char| c == ' ' || c == '\\t');\n            if !trimmed.is_empty() && !trimmed.ends_with('\\n') {\n                new_json.push('\\n');\n                new_json.push_str(tab.to_string().repeat(depth).as_str());\n            }\n            new_json.push_str(&comment);\n            // After block comment, add newline + indent for the next content\n            new_json.push('\\n');\n            new_json.push_str(tab.to_string().repeat(depth).as_str());\n            continue;\n        }\n\n        match current_char {\n            ',' => {\n                new_json.push(current_char);\n                new_json.push('\\n');\n                new_json.push_str(tab.to_string().repeat(depth).as_str());\n            }\n            '{' => match chars.peek() {\n                Some('}') => {\n                    new_json.push(current_char);\n                    new_json.push('}');\n                    chars.next(); // Skip }\n                }\n                _ => {\n                    depth += 1;\n                    new_json.push(current_char);\n                    new_json.push('\\n');\n                    new_json.push_str(tab.to_string().repeat(depth).as_str());\n                }\n            },\n            '[' => match chars.peek() {\n                Some(']') => {\n                    new_json.push(current_char);\n                    new_json.push(']');\n                    chars.next(); // Skip ]\n                }\n                _ => {\n                    depth += 1;\n                    new_json.push(current_char);\n                    new_json.push('\\n');\n                    new_json.push_str(tab.to_string().repeat(depth).as_str());\n                }\n            },\n            '}' => {\n                // Guard just in case invalid JSON has more closes than opens\n                if depth > 0 {\n                    depth -= 1;\n                }\n                new_json.push('\\n');\n                new_json.push_str(tab.to_string().repeat(depth).as_str());\n                new_json.push(current_char);\n            }\n            ']' => {\n                // Guard just in case invalid JSON has more closes than opens\n                if depth > 0 {\n                    depth -= 1;\n                }\n                new_json.push('\\n');\n                new_json.push_str(tab.to_string().repeat(depth).as_str());\n                new_json.push(current_char);\n            }\n            ':' => {\n                new_json.push(current_char);\n                new_json.push(' '); // Pad with space\n            }\n            '\"' => {\n                state = FormatState::String;\n                new_json.push(current_char);\n            }\n            _ => {\n                if current_char == ' '\n                    || current_char == '\\n'\n                    || current_char == '\\t'\n                    || current_char == '\\r'\n                {\n                    if current_char == '\\n' {\n                        saw_newline_in_whitespace = true;\n                    }\n                    // Don't add these\n                } else {\n                    saw_newline_in_whitespace = false;\n                    new_json.push(current_char);\n                }\n            }\n        }\n    }\n\n    // Filter out whitespace-only lines, but preserve empty lines inside block comments\n    let mut result_lines: Vec<&str> = Vec::new();\n    let mut in_block_comment = false;\n    for line in new_json.lines() {\n        if in_block_comment {\n            result_lines.push(line);\n            if line.contains(\"*/\") {\n                in_block_comment = false;\n            }\n        } else {\n            if line.contains(\"/*\") && !line.contains(\"*/\") {\n                in_block_comment = true;\n            }\n            if !line.trim().is_empty() {\n                result_lines.push(line);\n            }\n        }\n    }\n    result_lines.iter().map(|line| line.trim_end()).collect::<Vec<&str>>().join(\"\\n\")\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::format_json::format_json;\n\n    #[test]\n    fn test_simple_object() {\n        assert_eq!(\n            format_json(r#\"{\"foo\":\"bar\",\"baz\":\"qux\"}\"#, \"  \"),\n            r#\"\n{\n  \"foo\": \"bar\",\n  \"baz\": \"qux\"\n}\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_escaped() {\n        assert_eq!(\n            format_json(r#\"{\"foo\":\"Hi \\\"world!\\\"\"}\"#, \"  \"),\n            r#\"\n{\n  \"foo\": \"Hi \\\"world!\\\"\"\n}\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_simple_array() {\n        assert_eq!(\n            format_json(r#\"[\"foo\",\"bar\",\"baz\",\"qux\"]\"#, \"  \"),\n            r#\"\n[\n  \"foo\",\n  \"bar\",\n  \"baz\",\n  \"qux\"\n]\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_extra_whitespace() {\n        assert_eq!(\n            format_json(\n                r#\"[\"foo\",   \"bar\",  \"baz\",\"qux\"\n\n            ]\"#,\n                \"  \"\n            ),\n            r#\"\n[\n  \"foo\",\n  \"bar\",\n  \"baz\",\n  \"qux\"\n]\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_invalid_json() {\n        assert_eq!(\n            format_json(r#\"[\"foo\", {\"bar\",  }\"baz\",[\"qux\" ]]\"#, \"  \"),\n            r#\"\n[\n  \"foo\",\n  {\n    \"bar\",\n  }\"baz\",\n  [\n    \"qux\"\n  ]\n]\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_skip_template_tags() {\n        assert_eq!(\n            format_json(r#\"{\"foo\":${[ fn(\"hello\", \"world\") ]} }\"#, \"  \"),\n            r#\"\n{\n  \"foo\": ${[ fn(\"hello\", \"world\") ]}\n}\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_graphql_response() {\n        assert_eq!(\n            format_json(\n                r#\"{\"data\":{\"capsules\":[{\"landings\":null,\"original_launch\":null,\"reuse_count\":0,\"status\":\"retired\",\"type\":\"Dragon 1.0\",\"missions\":null},{\"id\":\"5e9e2c5bf3591882af3b2665\",\"landings\":null,\"original_launch\":null,\"reuse_count\":0,\"status\":\"retired\",\"type\":\"Dragon 1.0\",\"missions\":null}]}}\"#,\n                \"  \"\n            ),\n            r#\"\n{\n  \"data\": {\n    \"capsules\": [\n      {\n        \"landings\": null,\n        \"original_launch\": null,\n        \"reuse_count\": 0,\n        \"status\": \"retired\",\n        \"type\": \"Dragon 1.0\",\n        \"missions\": null\n      },\n      {\n        \"id\": \"5e9e2c5bf3591882af3b2665\",\n        \"landings\": null,\n        \"original_launch\": null,\n        \"reuse_count\": 0,\n        \"status\": \"retired\",\n        \"type\": \"Dragon 1.0\",\n        \"missions\": null\n      }\n    ]\n  }\n}\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_immediate_close() {\n        assert_eq!(\n            format_json(r#\"{\"bar\":[]}\"#, \"  \"),\n            r#\"\n{\n  \"bar\": []\n}\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_more_closes() {\n        assert_eq!(\n            format_json(r#\"{}}\"#, \"  \"),\n            r#\"\n{}\n}\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_line_comment_between_keys() {\n        assert_eq!(\n            format_json(\n                r#\"{\"foo\":\"bar\",// a comment\n\"baz\":\"qux\"}\"#,\n                \"  \"\n            ),\n            r#\"\n{\n  \"foo\": \"bar\", // a comment\n  \"baz\": \"qux\"\n}\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_line_comment_at_end() {\n        assert_eq!(\n            format_json(\n                r#\"{\"foo\":\"bar\" // trailing\n}\"#,\n                \"  \"\n            ),\n            r#\"\n{\n  \"foo\": \"bar\" // trailing\n}\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_block_comment() {\n        assert_eq!(\n            format_json(r#\"{\"foo\":\"bar\",/* comment */\"baz\":\"qux\"}\"#, \"  \"),\n            r#\"\n{\n  \"foo\": \"bar\",\n  /* comment */\n  \"baz\": \"qux\"\n}\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_comment_in_array() {\n        assert_eq!(\n            format_json(\n                r#\"[1,// item comment\n2,3]\"#,\n                \"  \"\n            ),\n            r#\"\n[\n  1, // item comment\n  2,\n  3\n]\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_comment_only_line() {\n        assert_eq!(\n            format_json(\n                r#\"{\n  // this is a standalone comment\n  \"foo\": \"bar\"\n}\"#,\n                \"  \"\n            ),\n            r#\"\n{\n  // this is a standalone comment\n  \"foo\": \"bar\"\n}\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_multiline_block_comment() {\n        assert_eq!(\n            format_json(\n                r#\"{\n  \"foo\": \"bar\"\n  /**\n   Hello World!\n\n   Hi there\n   */\n}\"#,\n                \"  \"\n            ),\n            r#\"\n{\n  \"foo\": \"bar\"\n  /**\n   Hello World!\n\n   Hi there\n   */\n}\n\"#\n            .trim()\n        );\n    }\n\n    // NOTE: trailing whitespace on output lines is trimmed by the formatter.\n    // We can't easily add a test for this because raw string literals get\n    // trailing whitespace stripped by the editor/linter.\n\n    #[test]\n    fn test_comment_inside_string_ignored() {\n        assert_eq!(\n            format_json(r#\"{\"foo\":\"// not a comment\",\"bar\":\"/* also not */\"}\"#, \"  \"),\n            r#\"\n{\n  \"foo\": \"// not a comment\",\n  \"bar\": \"/* also not */\"\n}\n\"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_comment_on_line_after_comma() {\n        assert_eq!(\n            format_json(\n                r#\"{\n  \"a\": \"aaa\",\n  // \"b\": \"bbb\"\n}\"#,\n                \"  \"\n            ),\n            r#\"\n{\n  \"a\": \"aaa\",\n  // \"b\": \"bbb\"\n}\n\"#\n            .trim()\n        );\n    }\n}\n"
  },
  {
    "path": "crates/yaak-templates/src/lib.rs",
    "content": "pub mod error;\npub mod escape;\npub mod format_json;\npub mod strip_json_comments;\npub mod parser;\npub mod renderer;\npub mod wasm;\n\npub use parser::*;\npub use renderer::*;\n"
  },
  {
    "path": "crates/yaak-templates/src/parser.rs",
    "content": "use crate::TemplateCallback;\nuse crate::error::Error::RenderError;\nuse crate::error::Result;\nuse base64::Engine;\nuse base64::prelude::BASE64_URL_SAFE_NO_PAD;\nuse serde::{Deserialize, Serialize};\nuse std::fmt::Display;\nuse ts_rs::TS;\n\n#[derive(Default, Clone, PartialEq, Debug, Serialize, Deserialize, TS)]\n#[ts(export, export_to = \"parser.ts\")]\npub struct Tokens {\n    pub tokens: Vec<Token>,\n}\n\nimpl Display for Tokens {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        let str = self.tokens.iter().map(|t| t.to_string()).collect::<Vec<String>>().join(\"\");\n        write!(f, \"{}\", str)\n    }\n}\n\n#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, TS)]\n#[ts(export, export_to = \"parser.ts\")]\npub struct FnArg {\n    pub name: String,\n    pub value: Val,\n}\n\nimpl Display for FnArg {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        let str = format!(\"{}={}\", self.name, self.value);\n        write!(f, \"{}\", str)\n    }\n}\n\n#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\", tag = \"type\")]\n#[ts(export, export_to = \"parser.ts\")]\npub enum Val {\n    Str { text: String },\n    Var { name: String },\n    Bool { value: bool },\n    Fn { name: String, args: Vec<FnArg> },\n    Null,\n}\n\nimpl Display for Val {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        let str = match self {\n            Val::Str { text } => {\n                if text.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '_' || c == '_') {\n                    format!(\"'{}'\", text)\n                } else {\n                    format!(\"b64'{}'\", BASE64_URL_SAFE_NO_PAD.encode(text))\n                }\n            }\n            Val::Var { name } => name.to_string(),\n            Val::Bool { value } => value.to_string(),\n            Val::Fn { name, args } => {\n                format!(\n                    \"{name}({})\",\n                    args.iter()\n                        .filter_map(|a| match a.value.clone() {\n                            Val::Null => None,\n                            _ => Some(a.to_string()),\n                        })\n                        .collect::<Vec<String>>()\n                        .join(\", \")\n                )\n            }\n            Val::Null => \"null\".to_string(),\n        };\n        write!(f, \"{}\", str)\n    }\n}\n\n#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\", tag = \"type\")]\n#[ts(export, export_to = \"parser.ts\")]\npub enum Token {\n    Raw { text: String },\n    Tag { val: Val },\n    Eof,\n}\n\nimpl Display for Token {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        let str = match self {\n            Token::Raw { text } => text.to_string(),\n            Token::Tag { val } => format!(\"${{[ {} ]}}\", val.to_string()),\n            Token::Eof => \"\".to_string(),\n        };\n        write!(f, \"{}\", str)\n    }\n}\n\nfn transform_val<T: TemplateCallback>(val: &Val, cb: &T) -> Result<Val> {\n    let val = match val {\n        Val::Fn { name: fn_name, args } => {\n            let mut new_args: Vec<FnArg> = Vec::new();\n            for arg in args {\n                let value = match arg.clone().value {\n                    Val::Str { text } => {\n                        let text = cb.transform_arg(&fn_name, &arg.name, &text)?;\n                        Val::Str { text }\n                    }\n                    v => transform_val(&v, cb)?,\n                };\n\n                let arg_name = arg.name.clone();\n                new_args.push(FnArg { name: arg_name, value });\n            }\n            Val::Fn { name: fn_name.clone(), args: new_args }\n        }\n        _ => val.clone(),\n    };\n    Ok(val)\n}\n\npub fn transform_args<T: TemplateCallback>(tokens: Tokens, cb: &T) -> Result<Tokens> {\n    let mut new_tokens = Tokens::default();\n    for t in tokens.tokens.iter() {\n        new_tokens.tokens.push(match t {\n            Token::Tag { val } => {\n                let val = transform_val(val, cb)?;\n                Token::Tag { val }\n            }\n            _ => t.clone(),\n        });\n    }\n\n    Ok(new_tokens)\n}\n\n// Template Syntax\n//\n//  ${[ my_var ]}\n//  ${[ my_fn() ]}\n//  ${[ my_fn(my_var) ]}\n//  ${[ my_fn(my_var, \"A String\") ]}\n\n// default\n#[derive(Default)]\npub struct Parser {\n    tokens: Vec<Token>,\n    chars: Vec<char>,\n    pos: usize,\n    curr_text: String,\n}\n\nimpl Parser {\n    pub fn new(text: &str) -> Parser {\n        Parser { chars: text.chars().collect(), ..Parser::default() }\n    }\n\n    pub fn parse(&mut self) -> Result<Tokens> {\n        let start_pos = self.pos;\n\n        while self.pos < self.chars.len() {\n            if self.match_str(r#\"\\\\\"#) {\n                // Skip double-escapes so we don't trigger our own escapes in the next case\n                self.curr_text += r#\"\\\\\"#;\n            } else if self.match_str(r#\"\\${[\"#) {\n                // Unescaped template syntax so we treat it as a string\n                self.curr_text += \"${[\";\n            } else if self.match_str(\"${[\") {\n                let start_curr = self.pos;\n                if let Some(t) = self.parse_tag()? {\n                    self.push_token(t);\n                } else {\n                    self.pos = start_curr;\n                    self.curr_text += \"${[\";\n                }\n            } else {\n                let ch = self.next_char();\n                self.curr_text.push(ch);\n            }\n\n            if start_pos == self.pos {\n                panic!(\"Parser stuck!\");\n            }\n        }\n\n        self.push_token(Token::Eof);\n        Ok(Tokens { tokens: self.tokens.clone() })\n    }\n\n    fn parse_tag(&mut self) -> Result<Option<Token>> {\n        // Parse up to first identifier\n        //    ${[ my_var...\n        self.skip_whitespace();\n\n        let val = match self.parse_value()? {\n            Some(v) => v,\n            None => return Ok(None),\n        };\n\n        // Parse to closing tag\n        //    ${[ my_var(a, b, c) ]}\n        self.skip_whitespace();\n        if !self.match_str(\"]}\") {\n            return Ok(None);\n        }\n\n        Ok(Some(Token::Tag { val }))\n    }\n\n    #[allow(dead_code)]\n    fn debug_pos(&self, x: &str) {\n        println!(\n            r#\"Position: {x}: text[{}]='{}' → \"{}\" → {:?}\"#,\n            self.pos,\n            self.chars[self.pos],\n            self.chars.iter().collect::<String>(),\n            self.tokens,\n        );\n    }\n\n    fn parse_value(&mut self) -> Result<Option<Val>> {\n        let v = if let Some((name, args)) = self.parse_fn()? {\n            Some(Val::Fn { name, args })\n        } else if let Some(v) = self.parse_string()? {\n            Some(Val::Str { text: v })\n        } else if let Some(v) = self.parse_ident() {\n            if v == \"null\" {\n                Some(Val::Null)\n            } else if v == \"true\" {\n                Some(Val::Bool { value: true })\n            } else if v == \"false\" {\n                Some(Val::Bool { value: false })\n            } else {\n                Some(Val::Var { name: v })\n            }\n        } else {\n            None\n        };\n\n        Ok(v)\n    }\n\n    fn parse_fn(&mut self) -> Result<Option<(String, Vec<FnArg>)>> {\n        let start_pos = self.pos;\n\n        let name = match self.parse_fn_name() {\n            Some(v) => v,\n            None => {\n                self.pos = start_pos;\n                return Ok(None);\n            }\n        };\n\n        let args = match self.parse_fn_args()? {\n            Some(args) => args,\n            None => {\n                self.pos = start_pos;\n                return Ok(None);\n            }\n        };\n\n        Ok(Some((name, args)))\n    }\n\n    fn parse_fn_args(&mut self) -> Result<Option<Vec<FnArg>>> {\n        if !self.match_str(\"(\") {\n            return Ok(None);\n        }\n\n        let start_pos = self.pos;\n\n        let mut args: Vec<FnArg> = Vec::new();\n\n        // Fn closed immediately\n        self.skip_whitespace();\n        if self.match_str(\")\") {\n            return Ok(Some(args));\n        }\n\n        while self.pos < self.chars.len() {\n            self.skip_whitespace();\n\n            let name = self.parse_ident();\n            self.skip_whitespace();\n            self.match_str(\"=\");\n            self.skip_whitespace();\n            let value = self.parse_value()?;\n            self.skip_whitespace();\n\n            if let (Some(name), Some(value)) = (name.clone(), value.clone()) {\n                args.push(FnArg { name, value });\n            } else {\n                // Didn't find valid thing, so return\n                self.pos = start_pos;\n                return Ok(None);\n            }\n\n            if self.match_str(\")\") {\n                break;\n            }\n\n            self.skip_whitespace();\n\n            // If we don't find a comma, that's bad\n            if !args.is_empty() && !self.match_str(\",\") {\n                self.pos = start_pos;\n                return Ok(None);\n            }\n\n            if start_pos == self.pos {\n                panic!(\"Parser stuck!\");\n            }\n        }\n\n        Ok(Some(args))\n    }\n\n    fn parse_ident(&mut self) -> Option<String> {\n        let start_pos = self.pos;\n\n        let mut text = String::new();\n        while self.pos < self.chars.len() {\n            let ch = self.peek_char();\n            let is_valid = if start_pos == self.pos {\n                ch.is_alphanumeric() || ch == '_' // The first char is more restrictive\n            } else {\n                ch.is_alphanumeric() || ch == '_' || ch == '-' || ch == '.'\n            };\n            if is_valid {\n                text.push(ch);\n                self.pos += 1;\n            } else {\n                break;\n            }\n\n            if start_pos == self.pos {\n                panic!(\"Parser stuck!\");\n            }\n        }\n\n        if text.is_empty() {\n            self.pos = start_pos;\n            return None;\n        }\n\n        Some(text)\n    }\n\n    fn parse_fn_name(&mut self) -> Option<String> {\n        let start_pos = self.pos;\n\n        let mut text = String::new();\n        while self.pos < self.chars.len() {\n            let ch = self.peek_char();\n            if ch.is_alphanumeric() || ch == '_' || ch == '.' {\n                text.push(ch);\n                self.pos += 1;\n            } else {\n                break;\n            }\n\n            if start_pos == self.pos {\n                panic!(\"Parser stuck!\");\n            }\n        }\n\n        if text.is_empty() {\n            self.pos = start_pos;\n            return None;\n        }\n\n        Some(text)\n    }\n\n    fn parse_string(&mut self) -> Result<Option<String>> {\n        let start_pos = self.pos;\n\n        let mut text = String::new();\n        let mut is_b64 = false;\n        if self.match_str(\"b64'\") {\n            is_b64 = true;\n        } else if self.match_str(\"'\") {\n            // Nothing\n        } else {\n            return Ok(None);\n        }\n\n        let mut found_closing = false;\n        while self.pos < self.chars.len() {\n            let ch = self.next_char();\n            match ch {\n                '\\\\' => {\n                    text.push(self.next_char());\n                }\n                '\\'' => {\n                    found_closing = true;\n                    break;\n                }\n                _ => {\n                    text.push(ch);\n                }\n            }\n\n            if start_pos == self.pos {\n                panic!(\"Parser stuck!\");\n            }\n        }\n\n        if !found_closing {\n            self.pos = start_pos;\n            return Ok(None);\n        }\n\n        let final_text = if is_b64 {\n            let decoded = BASE64_URL_SAFE_NO_PAD\n                .decode(text.clone())\n                .map_err(|_| RenderError(format!(\"Failed to decode string {text}\")))?;\n            let decoded = String::from_utf8(decoded)\n                .map_err(|_| RenderError(format!(\"Failed to decode utf8 string {text}\")))?;\n            decoded\n        } else {\n            text\n        };\n\n        Ok(Some(final_text))\n    }\n\n    fn skip_whitespace(&mut self) {\n        while self.pos < self.chars.len() {\n            if self.peek_char().is_whitespace() {\n                self.pos += 1;\n            } else {\n                break;\n            }\n        }\n    }\n\n    fn next_char(&mut self) -> char {\n        let ch = self.peek_char();\n\n        self.pos += 1;\n        ch\n    }\n\n    fn peek_char(&self) -> char {\n        let ch = self.chars[self.pos];\n        ch\n    }\n\n    fn push_token(&mut self, token: Token) {\n        // Push any text we've accumulated\n        if !self.curr_text.is_empty() {\n            let text_token = Token::Raw { text: self.curr_text.clone() };\n            self.tokens.push(text_token);\n            self.curr_text.clear();\n        }\n\n        self.tokens.push(token);\n    }\n\n    fn match_str(&mut self, value: &str) -> bool {\n        if self.pos + value.len() > self.chars.len() {\n            return false;\n        }\n\n        let cmp = self.chars[self.pos..self.pos + value.len()].iter().collect::<String>();\n\n        if cmp == value {\n            // We have a match, so advance the current index\n            self.pos += value.len();\n            true\n        } else {\n            false\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::Val::Null;\n    use crate::error::Result;\n    use crate::*;\n\n    #[test]\n    fn escaped() -> Result<()> {\n        let mut p = Parser::new(r#\"\\${[ foo ]}\"#);\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![Token::Raw { text: \"${[ foo ]}\".to_string() }, Token::Eof]\n        );\n        Ok(())\n    }\n\n    #[test]\n    fn escaped_tricky() -> Result<()> {\n        let mut p = Parser::new(r#\"\\\\${[ foo ]}\"#);\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Raw { text: r#\"\\\\\"#.to_string() },\n                Token::Tag { val: Val::Var { name: \"foo\".into() } },\n                Token::Eof\n            ]\n        );\n        Ok(())\n    }\n\n    #[test]\n    fn var_simple() -> Result<()> {\n        let mut p = Parser::new(\"${[ foo ]}\");\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag { val: Val::Var { name: \"foo\".into() } },\n                Token::Eof\n            ]\n        );\n        Ok(())\n    }\n\n    #[test]\n    fn var_dashes() -> Result<()> {\n        let mut p = Parser::new(\"${[ a-b ]}\");\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag { val: Val::Var { name: \"a-b\".into() } },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn var_underscores() -> Result<()> {\n        let mut p = Parser::new(\"${[ a_b ]}\");\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag { val: Val::Var { name: \"a_b\".into() } },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn var_dots() -> Result<()> {\n        let mut p = Parser::new(\"${[ a.b ]}\");\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag { val: Val::Var { name: \"a.b\".into() } },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn var_prefixes() -> Result<()> {\n        let mut p = Parser::new(\"${[ -a ]}${[ $a ]}\");\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Raw {\n                    // Shouldn't be parsed, because they're invalid\n                    text: \"${[ -a ]}${[ $a ]}\".into()\n                },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn var_underscore_prefix() -> Result<()> {\n        let mut p = Parser::new(\"${[ _a ]}\");\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag { val: Val::Var { name: \"_a\".into() } },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn var_boolean() -> Result<()> {\n        let mut p = Parser::new(\"${[ true ]}${[ false ]}\");\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag { val: Val::Bool { value: true } },\n                Token::Tag { val: Val::Bool { value: false } },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn var_multiple_names_invalid() -> Result<()> {\n        let mut p = Parser::new(\"${[ foo bar ]}\");\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![Token::Raw { text: \"${[ foo bar ]}\".into() }, Token::Eof]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn tag_string() -> Result<()> {\n        let mut p = Parser::new(r#\"${[ 'foo \\'bar\\' baz' ]}\"#);\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag { val: Val::Str { text: r#\"foo 'bar' baz\"#.into() } },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn tag_b64_string() -> Result<()> {\n        let mut p = Parser::new(r#\"${[ b64'Zm9vICdiYXInIGJheg' ]}\"#);\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag { val: Val::Str { text: r#\"foo 'bar' baz\"#.into() } },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn var_surrounded() -> Result<()> {\n        let mut p = Parser::new(\"Hello ${[ foo ]}!\");\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Raw { text: \"Hello \".to_string() },\n                Token::Tag { val: Val::Var { name: \"foo\".into() } },\n                Token::Raw { text: \"!\".to_string() },\n                Token::Eof,\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn fn_simple() -> Result<()> {\n        let mut p = Parser::new(\"${[ foo() ]}\");\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag { val: Val::Fn { name: \"foo\".into(), args: Vec::new() } },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn fn_dot_name() -> Result<()> {\n        let mut p = Parser::new(\"${[ foo.bar.baz() ]}\");\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag { val: Val::Fn { name: \"foo.bar.baz\".into(), args: Vec::new() } },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn fn_ident_arg() -> Result<()> {\n        let mut p = Parser::new(\"${[ foo(a=bar) ]}\");\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag {\n                    val: Val::Fn {\n                        name: \"foo\".into(),\n                        args: vec![FnArg {\n                            name: \"a\".into(),\n                            value: Val::Var { name: \"bar\".into() }\n                        }],\n                    }\n                },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn fn_ident_args() -> Result<()> {\n        let mut p = Parser::new(\"${[ foo(a=bar,b = baz, c =qux ) ]}\");\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag {\n                    val: Val::Fn {\n                        name: \"foo\".into(),\n                        args: vec![\n                            FnArg { name: \"a\".into(), value: Val::Var { name: \"bar\".into() } },\n                            FnArg { name: \"b\".into(), value: Val::Var { name: \"baz\".into() } },\n                            FnArg { name: \"c\".into(), value: Val::Var { name: \"qux\".into() } },\n                        ],\n                    }\n                },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn fn_mixed_args() -> Result<()> {\n        let mut p = Parser::new(r#\"${[ foo(aaa=bar,bb='baz \\'hi\\'', c=qux, z=true ) ]}\"#);\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag {\n                    val: Val::Fn {\n                        name: \"foo\".into(),\n                        args: vec![\n                            FnArg { name: \"aaa\".into(), value: Val::Var { name: \"bar\".into() } },\n                            FnArg {\n                                name: \"bb\".into(),\n                                value: Val::Str { text: r#\"baz 'hi'\"#.into() }\n                            },\n                            FnArg { name: \"c\".into(), value: Val::Var { name: \"qux\".into() } },\n                            FnArg { name: \"z\".into(), value: Val::Bool { value: true } },\n                        ],\n                    }\n                },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn fn_nested() -> Result<()> {\n        let mut p = Parser::new(\"${[ foo(b=bar()) ]}\");\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag {\n                    val: Val::Fn {\n                        name: \"foo\".into(),\n                        args: vec![FnArg {\n                            name: \"b\".into(),\n                            value: Val::Fn { name: \"bar\".into(), args: vec![] }\n                        }],\n                    }\n                },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn fn_nested_args() -> Result<()> {\n        let mut p = Parser::new(r#\"${[ outer(a=inner(a=foo, b='i'), c='o') ]}\"#);\n        assert_eq!(\n            p.parse()?.tokens,\n            vec![\n                Token::Tag {\n                    val: Val::Fn {\n                        name: \"outer\".into(),\n                        args: vec![\n                            FnArg {\n                                name: \"a\".into(),\n                                value: Val::Fn {\n                                    name: \"inner\".into(),\n                                    args: vec![\n                                        FnArg {\n                                            name: \"a\".into(),\n                                            value: Val::Var { name: \"foo\".into() }\n                                        },\n                                        FnArg {\n                                            name: \"b\".into(),\n                                            value: Val::Str { text: \"i\".into() },\n                                        },\n                                    ],\n                                }\n                            },\n                            FnArg { name: \"c\".into(), value: Val::Str { text: \"o\".into() } },\n                        ],\n                    }\n                },\n                Token::Eof\n            ]\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn token_display_var() -> Result<()> {\n        assert_eq!(Val::Var { name: \"foo\".to_string() }.to_string(), \"foo\");\n\n        Ok(())\n    }\n\n    #[test]\n    fn token_display_str() -> Result<()> {\n        assert_eq!(Val::Str { text: \"Hello You\".to_string() }.to_string(), \"'Hello You'\");\n\n        Ok(())\n    }\n\n    #[test]\n    fn token_display_complex_str() -> Result<()> {\n        assert_eq!(\n            Val::Str { text: \"Hello 'You'\".to_string() }.to_string(),\n            \"b64'SGVsbG8gJ1lvdSc'\"\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn token_null_fn_arg() -> Result<()> {\n        assert_eq!(\n            Val::Fn {\n                name: \"fn\".to_string(),\n                args: vec![\n                    FnArg { name: \"n\".to_string(), value: Null },\n                    FnArg { name: \"a\".to_string(), value: Val::Str { text: \"aaa\".to_string() } }\n                ]\n            }\n            .to_string(),\n            r#\"fn(a='aaa')\"#\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn token_display_fn() -> Result<()> {\n        assert_eq!(\n            Token::Tag {\n                val: Val::Fn {\n                    name: \"foo\".to_string(),\n                    args: vec![\n                        FnArg {\n                            name: \"arg\".to_string(),\n                            value: Val::Str { text: \"v 'x'\".to_string() }\n                        },\n                        FnArg {\n                            name: \"arg2\".to_string(),\n                            value: Val::Var { name: \"my_var\".to_string() }\n                        }\n                    ]\n                }\n            }\n            .to_string(),\n            r#\"${[ foo(arg=b64'diAneCc', arg2=my_var) ]}\"#\n        );\n\n        Ok(())\n    }\n\n    #[test]\n    fn tokens_display() -> Result<()> {\n        assert_eq!(\n            Tokens {\n                tokens: vec![\n                    Token::Tag { val: Val::Var { name: \"my_var\".to_string() } },\n                    Token::Raw { text: \" Some cool text \".to_string() },\n                    Token::Tag { val: Val::Str { text: \"Hello World\".to_string() } }\n                ]\n            }\n            .to_string(),\n            r#\"${[ my_var ]} Some cool text ${[ 'Hello World' ]}\"#\n        );\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/yaak-templates/src/renderer.rs",
    "content": "use crate::error::Error::{RenderStackExceededError, VariableNotFound};\nuse crate::error::Result;\nuse crate::{Parser, Token, Tokens, Val};\nuse log::warn;\nuse serde_json::json;\nuse std::collections::HashMap;\nuse std::future::Future;\n\nconst MAX_DEPTH: usize = 50;\n\npub trait TemplateCallback {\n    fn run(\n        &self,\n        fn_name: &str,\n        args: HashMap<String, serde_json::Value>,\n    ) -> impl Future<Output = Result<String>> + Send;\n\n    fn transform_arg(&self, fn_name: &str, arg_name: &str, arg_value: &str) -> Result<String>;\n}\n\npub async fn render_json_value_raw<T: TemplateCallback>(\n    v: serde_json::Value,\n    vars: &HashMap<String, String>,\n    cb: &T,\n    opt: &RenderOptions,\n) -> Result<serde_json::Value> {\n    let v = match v {\n        serde_json::Value::String(s) => json!(parse_and_render(&s, vars, cb, opt).await?),\n        serde_json::Value::Array(a) => {\n            let mut new_a = Vec::new();\n            for v in a {\n                new_a.push(Box::pin(render_json_value_raw(v, vars, cb, opt)).await?)\n            }\n            json!(new_a)\n        }\n        serde_json::Value::Object(o) => {\n            let mut new_o = serde_json::Map::new();\n            for (k, v) in o {\n                let key = Box::pin(parse_and_render(&k, vars, cb, opt)).await?;\n                let value = Box::pin(render_json_value_raw(v, vars, cb, opt)).await?;\n                new_o.insert(key, value);\n            }\n            json!(new_o)\n        }\n        v => v,\n    };\n    Ok(v)\n}\n\nasync fn parse_and_render_at_depth<T: TemplateCallback>(\n    template: &str,\n    vars: &HashMap<String, String>,\n    cb: &T,\n    opt: &RenderOptions,\n    depth: usize,\n) -> Result<String> {\n    let mut p = Parser::new(template);\n    let tokens = p.parse()?;\n    render(tokens, vars, cb, opt, depth + 1).await\n}\n\npub async fn parse_and_render<T: TemplateCallback>(\n    template: &str,\n    vars: &HashMap<String, String>,\n    cb: &T,\n    opt: &RenderOptions,\n) -> Result<String> {\n    parse_and_render_at_depth(template, vars, cb, opt, 1).await\n}\n\npub enum RenderErrorBehavior {\n    Throw,\n    ReturnEmpty,\n}\n\npub struct RenderOptions {\n    pub error_behavior: RenderErrorBehavior,\n}\n\nimpl RenderOptions {\n    pub fn throw() -> Self {\n        Self { error_behavior: RenderErrorBehavior::Throw }\n    }\n\n    pub fn return_empty() -> Self {\n        Self { error_behavior: RenderErrorBehavior::ReturnEmpty }\n    }\n}\n\nimpl RenderErrorBehavior {\n    pub fn handle(&self, r: Result<String>) -> Result<String> {\n        match (self, r) {\n            (_, Ok(v)) => Ok(v),\n            (RenderErrorBehavior::Throw, Err(e)) => Err(e),\n            (RenderErrorBehavior::ReturnEmpty, Err(e)) => {\n                warn!(\"Error rendering string: {}\", e);\n                Ok(\"\".to_string())\n            }\n        }\n    }\n}\n\npub async fn render<T: TemplateCallback>(\n    tokens: Tokens,\n    vars: &HashMap<String, String>,\n    cb: &T,\n    opt: &RenderOptions,\n    mut depth: usize,\n) -> Result<String> {\n    depth += 1;\n    if depth > MAX_DEPTH {\n        return opt.error_behavior.handle(Err(RenderStackExceededError));\n    }\n\n    let mut doc_str: Vec<String> = Vec::new();\n\n    for t in tokens.tokens {\n        match t {\n            Token::Raw { text } => doc_str.push(text),\n            Token::Tag { val } => {\n                let val = render_value(val, &vars, cb, opt, depth).await;\n                doc_str.push(opt.error_behavior.handle(val)?)\n            }\n            Token::Eof => {}\n        }\n    }\n\n    Ok(doc_str.join(\"\"))\n}\n\nasync fn render_value<T: TemplateCallback>(\n    val: Val,\n    vars: &HashMap<String, String>,\n    cb: &T,\n    opt: &RenderOptions,\n    depth: usize,\n) -> Result<String> {\n    let v = match val {\n        Val::Str { text } => {\n            let r = Box::pin(parse_and_render_at_depth(&text, vars, cb, opt, depth)).await?;\n            r.to_string()\n        }\n        Val::Var { name } => match vars.get(name.as_str()) {\n            Some(v) => {\n                let r = Box::pin(parse_and_render_at_depth(v, vars, cb, opt, depth)).await?;\n                r.to_string()\n            }\n            None => return Err(VariableNotFound(name)),\n        },\n        Val::Fn { name, args } => {\n            let mut resolved_args: HashMap<String, serde_json::Value> = HashMap::new();\n            for a in args {\n                let v = match a.value.clone() {\n                    Val::Bool { value } => serde_json::Value::Bool(value),\n                    Val::Null => serde_json::Value::Null,\n                    _ => serde_json::Value::String(\n                        Box::pin(render_value(a.value, vars, cb, opt, depth)).await?,\n                    ),\n                };\n                resolved_args.insert(a.name, v);\n            }\n            let result = cb.run(name.as_str(), resolved_args.clone()).await?;\n            Box::pin(parse_and_render_at_depth(&result, vars, cb, opt, depth)).await?\n        }\n        Val::Bool { value } => value.to_string(),\n        Val::Null => \"\".into(),\n    };\n\n    Ok(v)\n}\n\n#[cfg(test)]\nmod parse_and_render_tests {\n    use crate::error::Error::{RenderError, RenderStackExceededError, VariableNotFound};\n    use crate::error::Result;\n    use crate::renderer::TemplateCallback;\n    use crate::*;\n    use std::collections::HashMap;\n\n    struct EmptyCB {}\n\n    impl TemplateCallback for EmptyCB {\n        async fn run(\n            &self,\n            _fn_name: &str,\n            _args: HashMap<String, serde_json::Value>,\n        ) -> Result<String> {\n            todo!()\n        }\n\n        fn transform_arg(\n            &self,\n            _fn_name: &str,\n            _arg_name: &str,\n            arg_value: &str,\n        ) -> Result<String> {\n            Ok(arg_value.to_string())\n        }\n    }\n\n    #[tokio::test]\n    async fn render_empty() -> Result<()> {\n        let empty_cb = EmptyCB {};\n        let template = \"\";\n        let vars = HashMap::new();\n        let result = \"\";\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n        assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_text_only() -> Result<()> {\n        let empty_cb = EmptyCB {};\n        let template = \"Hello World!\";\n        let vars = HashMap::new();\n        let result = \"Hello World!\";\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n        assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_simple() -> Result<()> {\n        let empty_cb = EmptyCB {};\n        let template = \"${[ foo ]}\";\n        let vars = HashMap::from([(\"foo\".to_string(), \"bar\".to_string())]);\n        let result = \"bar\";\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n        assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_recursive_var() -> Result<()> {\n        let empty_cb = EmptyCB {};\n        let template = \"${[ foo ]}\";\n        let mut vars = HashMap::new();\n        vars.insert(\"foo\".to_string(), \"foo: ${[ bar ]}\".to_string());\n        vars.insert(\"bar\".to_string(), \"bar: ${[ baz ]}\".to_string());\n        vars.insert(\"baz\".to_string(), \"baz\".to_string());\n\n        let result = \"foo: bar: baz\";\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n        assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_missing_var() -> Result<()> {\n        let empty_cb = EmptyCB {};\n        let template = \"${[ foo ]}\";\n        let vars = HashMap::new();\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n        assert_eq!(\n            parse_and_render(template, &vars, &empty_cb, &opt).await,\n            Err(VariableNotFound(\"foo\".to_string()))\n        );\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_empty_var() -> Result<()> {\n        let empty_cb = EmptyCB {};\n        let template = \"${[ foo ]}\";\n        let mut vars = HashMap::new();\n        vars.insert(\"foo\".to_string(), \"\".to_string());\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n        assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await, Ok(\"\".to_string()));\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_self_referencing_var() -> Result<()> {\n        let empty_cb = EmptyCB {};\n        let template = \"${[ foo ]}\";\n        let mut vars = HashMap::new();\n        vars.insert(\"foo\".to_string(), \"${[ foo ]}\".to_string());\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n        assert_eq!(\n            parse_and_render(template, &vars, &empty_cb, &opt).await,\n            Err(RenderStackExceededError)\n        );\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_surrounded() -> Result<()> {\n        let empty_cb = EmptyCB {};\n        let template = \"hello ${[ word ]} world!\";\n        let vars = HashMap::from([(\"word\".to_string(), \"cruel\".to_string())]);\n        let result = \"hello cruel world!\";\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n        assert_eq!(parse_and_render(template, &vars, &empty_cb, &opt).await?, result.to_string());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_valid_fn() -> Result<()> {\n        let vars = HashMap::new();\n        let template = r#\"${[ say_hello(a='John', b='Kate') ]}\"#;\n        let result = r#\"say_hello: 2, Some(String(\"John\")) Some(String(\"Kate\"))\"#;\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n\n        struct CB {}\n        impl TemplateCallback for CB {\n            async fn run(\n                &self,\n                fn_name: &str,\n                args: HashMap<String, serde_json::Value>,\n            ) -> Result<String> {\n                Ok(format!(\"{fn_name}: {}, {:?} {:?}\", args.len(), args.get(\"a\"), args.get(\"b\")))\n            }\n\n            fn transform_arg(\n                &self,\n                _fn_name: &str,\n                _arg_name: &str,\n                arg_value: &str,\n            ) -> Result<String> {\n                Ok(arg_value.to_string())\n            }\n        }\n        assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result);\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_fn_arg() -> Result<()> {\n        let vars = HashMap::new();\n        let template = r#\"${[ upper(foo='bar') ]}\"#;\n        let result = r#\"\"BAR\"\"#;\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n        struct CB {}\n        impl TemplateCallback for CB {\n            async fn run(\n                &self,\n                fn_name: &str,\n                args: HashMap<String, serde_json::Value>,\n            ) -> Result<String> {\n                Ok(match fn_name {\n                    \"secret\" => \"abc\".to_string(),\n                    \"upper\" => args[\"foo\"].to_string().to_uppercase(),\n                    _ => \"\".to_string(),\n                })\n            }\n\n            fn transform_arg(\n                &self,\n                _fn_name: &str,\n                _arg_name: &str,\n                _arg_value: &str,\n            ) -> Result<String> {\n                todo!()\n            }\n        }\n\n        assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_fn_b64_arg_template() -> Result<()> {\n        let mut vars = HashMap::new();\n        vars.insert(\"foo\".to_string(), \"bar\".to_string());\n        let template = r#\"${[ upper(foo=b64'Zm9vICdiYXInIGJheg') ]}\"#;\n        let result = r#\"\"FOO 'BAR' BAZ\"\"#;\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n        struct CB {}\n        impl TemplateCallback for CB {\n            async fn run(\n                &self,\n                fn_name: &str,\n                args: HashMap<String, serde_json::Value>,\n            ) -> Result<String> {\n                Ok(match fn_name {\n                    \"upper\" => args[\"foo\"].to_string().to_uppercase(),\n                    _ => \"\".to_string(),\n                })\n            }\n\n            fn transform_arg(\n                &self,\n                _fn_name: &str,\n                _arg_name: &str,\n                _arg_value: &str,\n            ) -> Result<String> {\n                todo!()\n            }\n        }\n\n        assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_fn_arg_template() -> Result<()> {\n        let mut vars = HashMap::new();\n        vars.insert(\"foo\".to_string(), \"bar\".to_string());\n        let template = r#\"${[ upper(foo='${[ foo ]}') ]}\"#;\n        let result = r#\"\"BAR\"\"#;\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n\n        struct CB {}\n        impl TemplateCallback for CB {\n            async fn run(\n                &self,\n                fn_name: &str,\n                args: HashMap<String, serde_json::Value>,\n            ) -> Result<String> {\n                Ok(match fn_name {\n                    \"secret\" => \"abc\".to_string(),\n                    \"upper\" => args[\"foo\"].to_string().to_uppercase(),\n                    _ => \"\".to_string(),\n                })\n            }\n\n            fn transform_arg(\n                &self,\n                _fn_name: &str,\n                _arg_name: &str,\n                _arg_value: &str,\n            ) -> Result<String> {\n                todo!()\n            }\n        }\n\n        assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_fn_return_template() -> Result<()> {\n        let mut vars = HashMap::new();\n        vars.insert(\"foo\".to_string(), \"bar\".to_string());\n        let template = r#\"${[ no_op(inner='${[ foo ]}') ]}\"#;\n        let result = r#\"\"bar\"\"#;\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n\n        struct CB {}\n        impl TemplateCallback for CB {\n            async fn run(\n                &self,\n                fn_name: &str,\n                args: HashMap<String, serde_json::Value>,\n            ) -> Result<String> {\n                Ok(match fn_name {\n                    \"no_op\" => args[\"inner\"].to_string(),\n                    _ => \"\".to_string(),\n                })\n            }\n\n            fn transform_arg(\n                &self,\n                _fn_name: &str,\n                _arg_name: &str,\n                _arg_value: &str,\n            ) -> Result<String> {\n                todo!()\n            }\n        }\n\n        assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_nested_fn() -> Result<()> {\n        let vars = HashMap::new();\n        let template = r#\"${[ upper(foo=secret()) ]}\"#;\n        let result = r#\"\"ABC\"\"#;\n\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n        struct CB {}\n        impl TemplateCallback for CB {\n            async fn run(\n                &self,\n                fn_name: &str,\n                args: HashMap<String, serde_json::Value>,\n            ) -> Result<String> {\n                Ok(match fn_name {\n                    \"secret\" => \"abc\".to_string(),\n                    \"upper\" => args[\"foo\"].to_string().to_uppercase(),\n                    _ => \"\".to_string(),\n                })\n            }\n\n            fn transform_arg(\n                &self,\n                _fn_name: &str,\n                _arg_name: &str,\n                arg_value: &str,\n            ) -> Result<String> {\n                Ok(arg_value.to_string())\n            }\n        }\n        assert_eq!(parse_and_render(template, &vars, &CB {}, &opt).await?, result.to_string());\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_fn_err() -> Result<()> {\n        let vars = HashMap::new();\n        let template = r#\"hello ${[ error() ]}\"#;\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n\n        struct CB {}\n        impl TemplateCallback for CB {\n            async fn run(\n                &self,\n                _fn_name: &str,\n                _args: HashMap<String, serde_json::Value>,\n            ) -> Result<String> {\n                Err(RenderError(\"Failed to do it!\".to_string()))\n            }\n\n            fn transform_arg(\n                &self,\n                _fn_name: &str,\n                _arg_name: &str,\n                arg_value: &str,\n            ) -> Result<String> {\n                Ok(arg_value.to_string())\n            }\n        }\n\n        assert_eq!(\n            parse_and_render(template, &vars, &CB {}, &opt).await,\n            Err(RenderError(\"Failed to do it!\".to_string()))\n        );\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod render_json_value_raw_tests {\n    use crate::error::Result;\n    use crate::{\n        RenderErrorBehavior, RenderOptions, TemplateCallback, parse_and_render,\n        render_json_value_raw,\n    };\n    use serde_json::json;\n    use std::collections::HashMap;\n\n    struct EmptyCB {}\n\n    impl TemplateCallback for EmptyCB {\n        async fn run(\n            &self,\n            _fn_name: &str,\n            _args: HashMap<String, serde_json::Value>,\n        ) -> Result<String> {\n            todo!()\n        }\n\n        fn transform_arg(\n            &self,\n            _fn_name: &str,\n            _arg_name: &str,\n            arg_value: &str,\n        ) -> Result<String> {\n            Ok(arg_value.to_string())\n        }\n    }\n\n    #[tokio::test]\n    async fn render_json_value_string() -> Result<()> {\n        let v = json!(\"${[a]}\");\n        let mut vars = HashMap::new();\n        vars.insert(\"a\".to_string(), \"aaa\".to_string());\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n\n        assert_eq!(render_json_value_raw(v, &vars, &EmptyCB {}, &opt).await?, json!(\"aaa\"));\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_json_value_array() -> Result<()> {\n        let v = json!([\"${[a]}\", \"${[a]}\"]);\n        let mut vars = HashMap::new();\n        vars.insert(\"a\".to_string(), \"aaa\".to_string());\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n\n        let result = render_json_value_raw(v, &vars, &EmptyCB {}, &opt).await?;\n        assert_eq!(result, json!([\"aaa\", \"aaa\"]));\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_json_value_object() -> Result<()> {\n        let v = json!({\"${[a]}\": \"${[a]}\"});\n        let mut vars = HashMap::new();\n        vars.insert(\"a\".to_string(), \"aaa\".to_string());\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n\n        let result = render_json_value_raw(v, &vars, &EmptyCB {}, &opt).await?;\n        assert_eq!(result, json!({\"aaa\": \"aaa\"}));\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_json_value_nested() -> Result<()> {\n        let v = json!([\n            123,\n            {\"${[a]}\": \"${[a]}\"},\n            null,\n            \"${[a]}\",\n            false,\n            {\"x\": [\"${[a]}\"]}\n        ]);\n        let mut vars = HashMap::new();\n        vars.insert(\"a\".to_string(), \"aaa\".to_string());\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n\n        let result = render_json_value_raw(v, &vars, &EmptyCB {}, &opt).await?;\n        assert_eq!(\n            result,\n            json!([\n                123,\n                {\"aaa\": \"aaa\"},\n                null,\n                \"aaa\",\n                false,\n                {\"x\": [\"aaa\"]}\n            ])\n        );\n\n        Ok(())\n    }\n\n    #[tokio::test]\n    async fn render_opt_return_empty() -> Result<()> {\n        let vars = HashMap::new();\n        let opt = RenderOptions { error_behavior: RenderErrorBehavior::ReturnEmpty };\n\n        let result = parse_and_render(\"DNE: ${[hello]}\", &vars, &EmptyCB {}, &opt).await?;\n        assert_eq!(result, \"DNE: \".to_string());\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/yaak-templates/src/strip_json_comments.rs",
    "content": "/// Strips JSON comments only if the result is valid JSON. If stripping comments\n/// produces invalid JSON, the original text is returned unchanged.\npub fn maybe_strip_json_comments(text: &str) -> String {\n    let stripped = strip_json_comments(text);\n    if serde_json::from_str::<serde_json::Value>(&stripped).is_ok() {\n        stripped\n    } else {\n        text.to_string()\n    }\n}\n\n/// Strips comments from JSONC, preserving the original formatting as much as possible.\n///\n/// - Trailing comments on a line are removed (along with preceding whitespace)\n/// - Whole-line comments are removed, including the line itself\n/// - Block comments are removed, including any lines that become empty\n/// - Comments inside strings and template tags are left alone\npub fn strip_json_comments(text: &str) -> String {\n    let mut chars = text.chars().peekable();\n    let mut result = String::with_capacity(text.len());\n    let mut in_string = false;\n    let mut in_template_tag = false;\n\n    loop {\n        let current_char = match chars.next() {\n            None => break,\n            Some(c) => c,\n        };\n\n        // Handle JSON strings\n        if in_string {\n            result.push(current_char);\n            match current_char {\n                '\"' => in_string = false,\n                '\\\\' => {\n                    if let Some(c) = chars.next() {\n                        result.push(c);\n                    }\n                }\n                _ => {}\n            }\n            continue;\n        }\n\n        // Handle template tags\n        if in_template_tag {\n            result.push(current_char);\n            if current_char == ']' && chars.peek() == Some(&'}') {\n                result.push(chars.next().unwrap());\n                in_template_tag = false;\n            }\n            continue;\n        }\n\n        // Check for template tag start\n        if current_char == '$' && chars.peek() == Some(&'{') {\n            let mut lookahead = chars.clone();\n            lookahead.next(); // skip {\n            if lookahead.peek() == Some(&'[') {\n                in_template_tag = true;\n                result.push(current_char);\n                result.push(chars.next().unwrap()); // {\n                result.push(chars.next().unwrap()); // [\n                continue;\n            }\n        }\n\n        // Check for line comment\n        if current_char == '/' && chars.peek() == Some(&'/') {\n            chars.next(); // skip second /\n            // Consume until newline\n            loop {\n                match chars.peek() {\n                    Some(&'\\n') | None => break,\n                    Some(_) => {\n                        chars.next();\n                    }\n                }\n            }\n            // Trim trailing whitespace that preceded the comment\n            let trimmed_len = result.trim_end_matches(|c: char| c == ' ' || c == '\\t').len();\n            result.truncate(trimmed_len);\n            continue;\n        }\n\n        // Check for block comment\n        if current_char == '/' && chars.peek() == Some(&'*') {\n            chars.next(); // skip *\n            // Consume until */\n            loop {\n                match chars.next() {\n                    None => break,\n                    Some('*') if chars.peek() == Some(&'/') => {\n                        chars.next(); // skip /\n                        break;\n                    }\n                    Some(_) => {}\n                }\n            }\n            // Trim trailing whitespace that preceded the comment\n            let trimmed_len = result.trim_end_matches(|c: char| c == ' ' || c == '\\t').len();\n            result.truncate(trimmed_len);\n            // Skip whitespace/newline after the block comment if the next line is content\n            // (this handles the case where the block comment is on its own line)\n            continue;\n        }\n\n        if current_char == '\"' {\n            in_string = true;\n        }\n\n        result.push(current_char);\n    }\n\n    // Remove lines that are now empty (were comment-only lines)\n    let result = result\n        .lines()\n        .filter(|line| !line.trim().is_empty())\n        .collect::<Vec<&str>>()\n        .join(\"\\n\");\n\n    // Remove trailing commas before } or ]\n    strip_trailing_commas(&result)\n}\n\n/// Removes trailing commas before closing braces/brackets, respecting strings.\nfn strip_trailing_commas(text: &str) -> String {\n    let mut result = String::with_capacity(text.len());\n    let chars: Vec<char> = text.chars().collect();\n    let mut i = 0;\n    let mut in_string = false;\n\n    while i < chars.len() {\n        let ch = chars[i];\n\n        if in_string {\n            result.push(ch);\n            match ch {\n                '\"' => in_string = false,\n                '\\\\' => {\n                    i += 1;\n                    if i < chars.len() {\n                        result.push(chars[i]);\n                    }\n                }\n                _ => {}\n            }\n            i += 1;\n            continue;\n        }\n\n        if ch == '\"' {\n            in_string = true;\n            result.push(ch);\n            i += 1;\n            continue;\n        }\n\n        if ch == ',' {\n            // Look ahead past whitespace/newlines for } or ]\n            let mut j = i + 1;\n            while j < chars.len() && chars[j].is_whitespace() {\n                j += 1;\n            }\n            if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {\n                // Skip the comma\n                i += 1;\n                continue;\n            }\n        }\n\n        result.push(ch);\n        i += 1;\n    }\n\n    result\n}\n\n#[cfg(test)]\nmod tests {\n    use crate::strip_json_comments::strip_json_comments;\n\n    #[test]\n    fn test_no_comments() {\n        let input = r#\"{\n  \"foo\": \"bar\",\n  \"baz\": 123\n}\"#;\n        assert_eq!(strip_json_comments(input), input);\n    }\n\n    #[test]\n    fn test_trailing_line_comment() {\n        assert_eq!(\n            strip_json_comments(r#\"{\n  \"foo\": \"bar\", // this is a comment\n  \"baz\": 123\n}\"#),\n            r#\"{\n  \"foo\": \"bar\",\n  \"baz\": 123\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_whole_line_comment() {\n        assert_eq!(\n            strip_json_comments(r#\"{\n  // this is a comment\n  \"foo\": \"bar\"\n}\"#),\n            r#\"{\n  \"foo\": \"bar\"\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_inline_block_comment() {\n        assert_eq!(\n            strip_json_comments(r#\"{\n  \"foo\": /* a comment */ \"bar\"\n}\"#),\n            r#\"{\n  \"foo\": \"bar\"\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_whole_line_block_comment() {\n        assert_eq!(\n            strip_json_comments(r#\"{\n  /* a comment */\n  \"foo\": \"bar\"\n}\"#),\n            r#\"{\n  \"foo\": \"bar\"\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_multiline_block_comment() {\n        assert_eq!(\n            strip_json_comments(r#\"{\n  /**\n   * Hello World!\n   */\n  \"foo\": \"bar\"\n}\"#),\n            r#\"{\n  \"foo\": \"bar\"\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_comment_inside_string_preserved() {\n        let input = r#\"{\n  \"foo\": \"// not a comment\",\n  \"bar\": \"/* also not */\"\n}\"#;\n        assert_eq!(strip_json_comments(input), input);\n    }\n\n    #[test]\n    fn test_comment_inside_template_tag_preserved() {\n        let input = r#\"{\n  \"foo\": ${[ fn(\"// hi\", \"/* hey */\") ]}\n}\"#;\n        assert_eq!(strip_json_comments(input), input);\n    }\n\n    #[test]\n    fn test_multiple_comments() {\n        assert_eq!(\n            strip_json_comments(r#\"{\n  // first comment\n  \"foo\": \"bar\", // trailing\n  /* block */\n  \"baz\": 123\n}\"#),\n            r#\"{\n  \"foo\": \"bar\",\n  \"baz\": 123\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_trailing_comma_after_comment_removed() {\n        assert_eq!(\n            strip_json_comments(r#\"{\n  \"a\": \"aaa\",\n  // \"b\": \"bbb\"\n}\"#),\n            r#\"{\n  \"a\": \"aaa\"\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_trailing_comma_in_array() {\n        assert_eq!(\n            strip_json_comments(r#\"[1, 2, /* 3 */]\"#),\n            r#\"[1, 2]\"#\n        );\n    }\n\n    #[test]\n    fn test_comma_inside_string_preserved() {\n        let input = r#\"{\"a\": \"hello,}\"#;\n        assert_eq!(strip_json_comments(input), input);\n    }\n}\n"
  },
  {
    "path": "crates/yaak-templates/src/wasm.rs",
    "content": "use crate::error::Result;\nuse crate::{Parser, escape};\nuse wasm_bindgen::JsValue;\nuse wasm_bindgen::prelude::wasm_bindgen;\n\n#[wasm_bindgen]\npub fn parse_template(template: &str) -> Result<JsValue> {\n    let tokens = Parser::new(template).parse()?;\n    Ok(serde_wasm_bindgen::to_value(&tokens).unwrap())\n}\n\n#[wasm_bindgen]\npub fn escape_template(template: &str) -> Result<JsValue> {\n    let escaped = escape::escape_template(template);\n    Ok(serde_wasm_bindgen::to_value(&escaped).unwrap())\n}\n\n#[wasm_bindgen]\npub fn unescape_template(template: &str) -> Result<JsValue> {\n    let escaped = escape::unescape_template(template);\n    Ok(serde_wasm_bindgen::to_value(&escaped).unwrap())\n}\n"
  },
  {
    "path": "crates/yaak-tls/Cargo.toml",
    "content": "[package]\nname = \"yaak-tls\"\nversion = \"0.1.0\"\nedition = \"2021\"\npublish = false\n\n[dependencies]\nlog = { workspace = true }\np12 = \"0.6.3\"\nrustls = { workspace = true, default-features = false, features = [\"ring\"] }\nrustls-pemfile = \"2\"\nrustls-platform-verifier = { workspace = true }\nserde = { workspace = true, features = [\"derive\"] }\nthiserror = \"2.0.17\"\nurl = \"2.5\"\nyaak-models = { workspace = true }\n"
  },
  {
    "path": "crates/yaak-tls/src/error.rs",
    "content": "use serde::{Serialize, Serializer};\nuse std::io;\nuse thiserror::Error;\n\n#[derive(Error, Debug)]\npub enum Error {\n    #[error(\"Rustls error: {0}\")]\n    RustlsError(#[from] rustls::Error),\n\n    #[error(\"I/O error: {0}\")]\n    IOError(#[from] io::Error),\n\n    #[error(\"TLS error: {0}\")]\n    GenericError(String),\n}\n\nimpl Serialize for Error {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        serializer.serialize_str(self.to_string().as_ref())\n    }\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates/yaak-tls/src/lib.rs",
    "content": "use crate::error::Error::GenericError;\nuse crate::error::Result;\nuse log::debug;\nuse rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};\nuse rustls::crypto::ring;\nuse rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};\nuse rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};\nuse rustls_platform_verifier::BuilderVerifierExt;\nuse std::fs;\nuse std::io::BufReader;\nuse std::path::Path;\nuse std::str::FromStr;\nuse std::sync::Arc;\n\npub mod error;\n\n#[derive(Clone, Default)]\npub struct ClientCertificateConfig {\n    pub crt_file: Option<String>,\n    pub key_file: Option<String>,\n    pub pfx_file: Option<String>,\n    pub passphrase: Option<String>,\n}\n\npub fn get_tls_config(\n    validate_certificates: bool,\n    with_alpn: bool,\n    client_cert: Option<ClientCertificateConfig>,\n) -> Result<ClientConfig> {\n    let maybe_client_cert = load_client_cert(client_cert)?;\n\n    let mut client = if validate_certificates {\n        build_with_validation(maybe_client_cert)\n    } else {\n        build_without_validation(maybe_client_cert)\n    }?;\n\n    if with_alpn {\n        client.alpn_protocols = vec![b\"h2\".to_vec(), b\"http/1.1\".to_vec()];\n    }\n\n    Ok(client)\n}\n\nfn build_with_validation(\n    client_cert: Option<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)>,\n) -> Result<ClientConfig> {\n    let arc_crypto_provider = Arc::new(ring::default_provider());\n    let builder = ClientConfig::builder_with_provider(arc_crypto_provider)\n        .with_safe_default_protocol_versions()?\n        .with_platform_verifier()?;\n\n    if let Some((certs, key)) = client_cert {\n        return Ok(builder.with_client_auth_cert(certs, key)?);\n    }\n\n    Ok(builder.with_no_client_auth())\n}\n\nfn build_without_validation(\n    client_cert: Option<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)>,\n) -> Result<ClientConfig> {\n    let arc_crypto_provider = Arc::new(ring::default_provider());\n    let builder = ClientConfig::builder_with_provider(arc_crypto_provider)\n        .with_safe_default_protocol_versions()?\n        .dangerous()\n        .with_custom_certificate_verifier(Arc::new(NoVerifier));\n\n    if let Some((certs, key)) = client_cert {\n        return Ok(builder.with_client_auth_cert(certs, key)?);\n    }\n\n    Ok(builder.with_no_client_auth())\n}\n\nfn load_client_cert(\n    client_cert: Option<ClientCertificateConfig>,\n) -> Result<Option<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)>> {\n    let config = match client_cert {\n        None => return Ok(None),\n        Some(c) => c,\n    };\n\n    // Try PFX/PKCS12 first\n    if let Some(pfx_path) = &config.pfx_file {\n        if !pfx_path.is_empty() {\n            return Ok(Some(load_pkcs12(pfx_path, config.passphrase.as_deref().unwrap_or(\"\"))?));\n        }\n    }\n\n    // Try CRT + KEY files\n    if let (Some(crt_path), Some(key_path)) = (&config.crt_file, &config.key_file) {\n        if !crt_path.is_empty() && !key_path.is_empty() {\n            return Ok(Some(load_pem_files(crt_path, key_path)?));\n        }\n    }\n\n    Ok(None)\n}\n\nfn load_pem_files(\n    crt_path: &str,\n    key_path: &str,\n) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {\n    // Load certificates\n    let crt_file = fs::File::open(Path::new(crt_path))?;\n    let mut crt_reader = BufReader::new(crt_file);\n    let certs: Vec<CertificateDer<'static>> =\n        rustls_pemfile::certs(&mut crt_reader).filter_map(|r| r.ok()).collect();\n\n    if certs.is_empty() {\n        return Err(GenericError(\"No certificates found in CRT file\".to_string()));\n    }\n\n    // Load private key\n    let key_data = fs::read(Path::new(key_path))?;\n    let key = load_private_key(&key_data)?;\n\n    Ok((certs, key))\n}\n\nfn load_private_key(data: &[u8]) -> Result<PrivateKeyDer<'static>> {\n    let mut reader = BufReader::new(data);\n\n    // Try PKCS8 first\n    if let Some(key) = rustls_pemfile::pkcs8_private_keys(&mut reader).filter_map(|r| r.ok()).next()\n    {\n        return Ok(PrivateKeyDer::Pkcs8(key));\n    }\n\n    // Reset reader and try RSA\n    let mut reader = BufReader::new(data);\n    if let Some(key) = rustls_pemfile::rsa_private_keys(&mut reader).filter_map(|r| r.ok()).next() {\n        return Ok(PrivateKeyDer::Pkcs1(key));\n    }\n\n    // Reset reader and try EC\n    let mut reader = BufReader::new(data);\n    if let Some(key) = rustls_pemfile::ec_private_keys(&mut reader).filter_map(|r| r.ok()).next() {\n        return Ok(PrivateKeyDer::Sec1(key));\n    }\n\n    Err(GenericError(\"Could not parse private key\".to_string()))\n}\n\nfn load_pkcs12(\n    path: &str,\n    passphrase: &str,\n) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {\n    let data = fs::read(Path::new(path))?;\n\n    let pfx = p12::PFX::parse(&data)\n        .map_err(|e| GenericError(format!(\"Failed to parse PFX: {:?}\", e)))?;\n\n    let keys = pfx\n        .key_bags(passphrase)\n        .map_err(|e| GenericError(format!(\"Failed to extract keys: {:?}\", e)))?;\n\n    let certs = pfx\n        .cert_x509_bags(passphrase)\n        .map_err(|e| GenericError(format!(\"Failed to extract certs: {:?}\", e)))?;\n\n    if keys.is_empty() {\n        return Err(GenericError(\"No private key found in PFX\".to_string()));\n    }\n\n    if certs.is_empty() {\n        return Err(GenericError(\"No certificates found in PFX\".to_string()));\n    }\n\n    // Convert certificates - p12 crate returns Vec<u8> for each cert\n    let cert_ders: Vec<CertificateDer<'static>> =\n        certs.into_iter().map(|c| CertificateDer::from(c)).collect();\n\n    // Convert key - the p12 crate returns raw key bytes\n    let key_bytes = keys.into_iter().next().unwrap();\n    let key = PrivateKeyDer::Pkcs8(key_bytes.into());\n\n    Ok((cert_ders, key))\n}\n\n// Copied from reqwest: https://github.com/seanmonstar/reqwest/blob/595c80b1fbcdab73ac2ae93e4edc3406f453df25/src/tls.rs#L608\n#[derive(Debug)]\nstruct NoVerifier;\n\nimpl ServerCertVerifier for NoVerifier {\n    fn verify_server_cert(\n        &self,\n        _end_entity: &CertificateDer,\n        _intermediates: &[CertificateDer],\n        _server_name: &ServerName,\n        _ocsp_response: &[u8],\n        _now: UnixTime,\n    ) -> std::result::Result<ServerCertVerified, rustls::Error> {\n        Ok(ServerCertVerified::assertion())\n    }\n\n    fn verify_tls12_signature(\n        &self,\n        _message: &[u8],\n        _cert: &CertificateDer,\n        _dss: &DigitallySignedStruct,\n    ) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {\n        Ok(HandshakeSignatureValid::assertion())\n    }\n\n    fn verify_tls13_signature(\n        &self,\n        _message: &[u8],\n        _cert: &CertificateDer,\n        _dss: &DigitallySignedStruct,\n    ) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {\n        Ok(HandshakeSignatureValid::assertion())\n    }\n\n    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {\n        vec![\n            SignatureScheme::RSA_PKCS1_SHA1,\n            SignatureScheme::ECDSA_SHA1_Legacy,\n            SignatureScheme::RSA_PKCS1_SHA256,\n            SignatureScheme::ECDSA_NISTP256_SHA256,\n            SignatureScheme::RSA_PKCS1_SHA384,\n            SignatureScheme::ECDSA_NISTP384_SHA384,\n            SignatureScheme::RSA_PKCS1_SHA512,\n            SignatureScheme::ECDSA_NISTP521_SHA512,\n            SignatureScheme::RSA_PSS_SHA256,\n            SignatureScheme::RSA_PSS_SHA384,\n            SignatureScheme::RSA_PSS_SHA512,\n            SignatureScheme::ED25519,\n            SignatureScheme::ED448,\n        ]\n    }\n}\n\npub fn find_client_certificate(\n    url_string: &str,\n    certificates: &[yaak_models::models::ClientCertificate],\n) -> Option<ClientCertificateConfig> {\n    let url = url::Url::from_str(url_string).ok()?;\n    let host = url.host_str()?;\n    let port = url.port_or_known_default();\n\n    for cert in certificates {\n        if !cert.enabled {\n            debug!(\"Client certificate is disabled, skipping\");\n            continue;\n        }\n\n        // Match host (case-insensitive)\n        if !cert.host.eq_ignore_ascii_case(host) {\n            continue;\n        }\n\n        // Match port if specified in the certificate config\n        let cert_port = cert.port.unwrap_or(443);\n        if let Some(url_port) = port {\n            if cert_port != url_port as i32 {\n                debug!(\n                    \"Client certificate port does not match {} != {} (cert)\",\n                    url_port, cert_port\n                );\n                continue;\n            }\n        }\n\n        // Found a matching certificate\n        debug!(\"Found matching client certificate host={} port={}\", host, port.unwrap_or(443));\n        return Some(ClientCertificateConfig {\n            crt_file: cert.crt_file.clone(),\n            key_file: cert.key_file.clone(),\n            pfx_file: cert.pfx_file.clone(),\n            passphrase: cert.passphrase.clone(),\n        });\n    }\n\n    None\n}\n"
  },
  {
    "path": "crates/yaak-ws/Cargo.toml",
    "content": "[package]\nname = \"yaak-ws\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nfutures-util = \"0.3.31\"\nhttp = \"1\"\nlog = { workspace = true }\nmd5 = \"0.8.0\"\nserde = { workspace = true, features = [\"derive\"] }\nurl = \"2\"\nserde_json = { workspace = true }\nthiserror = { workspace = true }\ntokio = { workspace = true, features = [\"macros\", \"time\", \"test-util\", \"rt\"] }\ntokio-tungstenite = { version = \"0.26.2\", default-features = false, features = [\n  \"rustls-tls-native-roots\",\n  \"connect\",\n] }\nyaak-http = { workspace = true }\nyaak-tls = { workspace = true }\nyaak-models = { workspace = true }\nyaak-templates = { workspace = true }\n"
  },
  {
    "path": "crates/yaak-ws/index.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport { WebsocketConnection } from \"@yaakapp-internal/models\";\n\nexport function deleteWebsocketConnections(requestId: string) {\n  return invoke(\"cmd_ws_delete_connections\", {\n    requestId,\n  });\n}\n\nexport function connectWebsocket({\n  requestId,\n  environmentId,\n  cookieJarId,\n}: {\n  requestId: string;\n  environmentId: string | null;\n  cookieJarId: string | null;\n}) {\n  return invoke(\"cmd_ws_connect\", {\n    requestId,\n    environmentId,\n    cookieJarId,\n  }) as Promise<WebsocketConnection>;\n}\n\nexport function closeWebsocket({ connectionId }: { connectionId: string }) {\n  return invoke(\"cmd_ws_close\", {\n    connectionId,\n  });\n}\n\nexport function sendWebsocket({\n  connectionId,\n  environmentId,\n}: {\n  connectionId: string;\n  environmentId: string | null;\n}) {\n  return invoke(\"cmd_ws_send\", {\n    connectionId,\n    environmentId,\n  });\n}\n"
  },
  {
    "path": "crates/yaak-ws/package.json",
    "content": "{\n  \"name\": \"@yaakapp-internal/ws\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"index.ts\"\n}\n"
  },
  {
    "path": "crates/yaak-ws/src/connect.rs",
    "content": "use crate::error::Result;\nuse http::HeaderMap;\nuse log::info;\nuse std::sync::Arc;\nuse tokio::net::TcpStream;\nuse tokio_tungstenite::tungstenite::client::IntoClientRequest;\nuse tokio_tungstenite::tungstenite::handshake::client::Response;\nuse tokio_tungstenite::tungstenite::http::HeaderValue;\nuse tokio_tungstenite::tungstenite::protocol::WebSocketConfig;\nuse tokio_tungstenite::{\n    Connector, MaybeTlsStream, WebSocketStream, connect_async_tls_with_config,\n};\nuse yaak_tls::{ClientCertificateConfig, get_tls_config};\n\n// Enabling ALPN breaks websocket requests\nconst WITH_ALPN: bool = false;\n\npub async fn ws_connect(\n    url: &str,\n    headers: HeaderMap<HeaderValue>,\n    validate_certificates: bool,\n    client_cert: Option<ClientCertificateConfig>,\n) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> {\n    info!(\"Connecting to WS {url}\");\n    let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?;\n\n    let mut req = url.into_client_request()?;\n    let req_headers = req.headers_mut();\n    for (name, value) in headers {\n        if let Some(name) = name {\n            req_headers.insert(name, value);\n        }\n    }\n\n    let (stream, response) = connect_async_tls_with_config(\n        req,\n        Some(WebSocketConfig::default()),\n        false,\n        Some(Connector::Rustls(Arc::new(tls_config))),\n    )\n    .await?;\n\n    info!(\n        \"Connected to WS {url} validate_certificates={} client_cert={}\",\n        validate_certificates,\n        client_cert.is_some()\n    );\n\n    Ok((stream, response))\n}\n"
  },
  {
    "path": "crates/yaak-ws/src/error.rs",
    "content": "use serde::{Serialize, Serializer};\nuse thiserror::Error;\nuse tokio_tungstenite::tungstenite;\n\n#[derive(Error, Debug)]\npub enum Error {\n    #[error(\"WebSocket error: {0}\")]\n    WebSocketErr(#[from] tungstenite::Error),\n\n    #[error(transparent)]\n    ModelError(#[from] yaak_models::error::Error),\n\n    #[error(transparent)]\n    TemplateError(#[from] yaak_templates::error::Error),\n\n    #[error(transparent)]\n    TlsError(#[from] yaak_tls::error::Error),\n\n    #[error(\"WebSocket error: {0}\")]\n    GenericError(String),\n}\n\nimpl Serialize for Error {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        serializer.serialize_str(self.to_string().as_ref())\n    }\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates/yaak-ws/src/lib.rs",
    "content": "mod connect;\npub mod error;\npub mod manager;\npub mod render;\n\npub use connect::ws_connect;\npub use manager::WebsocketManager;\npub use render::render_websocket_request;\n\n// Re-export http types needed by consumers\npub use http::HeaderMap;\npub use tokio_tungstenite::tungstenite::http::HeaderValue;\n"
  },
  {
    "path": "crates/yaak-ws/src/manager.rs",
    "content": "use crate::connect::ws_connect;\nuse crate::error::Result;\nuse futures_util::stream::SplitSink;\nuse futures_util::{SinkExt, StreamExt};\nuse http::HeaderMap;\nuse log::{debug, info, warn};\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::net::TcpStream;\nuse tokio::sync::{Mutex, mpsc};\nuse tokio_tungstenite::tungstenite::Message;\nuse tokio_tungstenite::tungstenite::handshake::client::Response;\nuse tokio_tungstenite::tungstenite::http::HeaderValue;\nuse tokio_tungstenite::{MaybeTlsStream, WebSocketStream};\nuse yaak_tls::ClientCertificateConfig;\n\n#[derive(Clone)]\npub struct WebsocketManager {\n    connections:\n        Arc<Mutex<HashMap<String, SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>>,\n    read_tasks: Arc<Mutex<HashMap<String, tokio::task::JoinHandle<()>>>>,\n}\n\nimpl WebsocketManager {\n    pub fn new() -> Self {\n        WebsocketManager { connections: Default::default(), read_tasks: Default::default() }\n    }\n\n    pub async fn connect(\n        &mut self,\n        id: &str,\n        url: &str,\n        headers: HeaderMap<HeaderValue>,\n        receive_tx: mpsc::Sender<Message>,\n        validate_certificates: bool,\n        client_cert: Option<ClientCertificateConfig>,\n    ) -> Result<Response> {\n        let tx = receive_tx.clone();\n\n        let (stream, response) =\n            ws_connect(url, headers, validate_certificates, client_cert).await?;\n        let (write, mut read) = stream.split();\n\n        self.connections.lock().await.insert(id.to_string(), write);\n\n        let handle = {\n            let connection_id = id.to_string();\n            let connections = self.connections.clone();\n            let read_tasks = self.read_tasks.clone();\n            tokio::task::spawn(async move {\n                while let Some(msg) = read.next().await {\n                    match msg {\n                        Err(e) => {\n                            warn!(\"Broken websocket connection: {}\", e);\n                            break;\n                        }\n                        Ok(message) => tx.send(message).await.unwrap(),\n                    }\n                }\n                debug!(\"Connection {} closed\", connection_id);\n                connections.lock().await.remove(&connection_id);\n                read_tasks.lock().await.remove(&connection_id);\n            })\n        };\n\n        self.read_tasks.lock().await.insert(id.to_string(), handle);\n\n        Ok(response)\n    }\n\n    pub async fn send(&mut self, id: &str, msg: Message) -> Result<()> {\n        debug!(\"Send websocket message {msg:?}\");\n        let mut connections = self.connections.lock().await;\n        let connection = match connections.get_mut(id) {\n            None => return Ok(()),\n            Some(c) => c,\n        };\n        connection.send(msg).await?;\n        Ok(())\n    }\n\n    pub async fn close(&mut self, id: &str) -> Result<()> {\n        info!(\"Closing websocket\");\n        if let Some(mut connection) = self.connections.lock().await.remove(id) {\n            // Wait a maximum of 1 second for the connection to close\n            if let Err(e) = connection.close().await {\n                warn!(\"Failed to close websocket connection {e:?}\");\n            };\n        }\n\n        // Wait at short time for the server to close the connection, then stop\n        // reading.\n        tokio::time::sleep(Duration::from_millis(500)).await;\n        if let Some(handle) = self.read_tasks.lock().await.remove(id) {\n            handle.abort();\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "crates/yaak-ws/src/render.rs",
    "content": "use crate::error::Result;\nuse log::info;\nuse serde_json::Value;\nuse std::collections::BTreeMap;\nuse yaak_models::models::{Environment, HttpRequestHeader, HttpUrlParameter, WebsocketRequest};\nuse yaak_models::render::make_vars_hashmap;\nuse yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};\n\npub async fn render_websocket_request<T: TemplateCallback>(\n    r: &WebsocketRequest,\n    environment_chain: Vec<Environment>,\n    cb: &T,\n    opt: &RenderOptions,\n) -> Result<WebsocketRequest> {\n    let vars = &make_vars_hashmap(environment_chain);\n\n    let mut url_parameters = Vec::new();\n    for p in r.url_parameters.clone() {\n        if !p.enabled {\n            continue;\n        }\n        url_parameters.push(HttpUrlParameter {\n            enabled: p.enabled,\n            name: parse_and_render(&p.name, vars, cb, opt).await?,\n            value: parse_and_render(&p.value, vars, cb, opt).await?,\n            id: p.id,\n        })\n    }\n\n    let mut headers = Vec::new();\n    for p in r.headers.clone() {\n        if !p.enabled {\n            continue;\n        }\n        headers.push(HttpRequestHeader {\n            enabled: p.enabled,\n            name: parse_and_render(&p.name, vars, cb, opt).await?,\n            value: parse_and_render(&p.value, vars, cb, opt).await?,\n            id: p.id,\n        })\n    }\n\n    let authentication = {\n        let mut disabled = false;\n        let mut auth = BTreeMap::new();\n        match r.authentication.get(\"disabled\") {\n            Some(Value::Bool(true)) => {\n                disabled = true;\n            }\n            Some(Value::String(tmpl)) => {\n                disabled = parse_and_render(tmpl.as_str(), vars, cb, &opt)\n                    .await\n                    .unwrap_or_default()\n                    .is_empty();\n                info!(\n                    \"Rendering authentication.disabled as a template: {disabled} from \\\"{tmpl}\\\"\"\n                );\n            }\n            _ => {}\n        }\n        if disabled {\n            auth.insert(\"disabled\".to_string(), Value::Bool(true));\n        } else {\n            for (k, v) in r.authentication.clone() {\n                if k == \"disabled\" {\n                    auth.insert(k, Value::Bool(false));\n                } else {\n                    auth.insert(k, render_json_value_raw(v, vars, cb, &opt).await?);\n                }\n            }\n        }\n        auth\n    };\n\n    let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?;\n\n    let message = parse_and_render(&r.message.clone(), vars, cb, opt).await?;\n\n    Ok(WebsocketRequest { url, url_parameters, headers, authentication, message, ..r.to_owned() })\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/Cargo.toml",
    "content": "[package]\nname = \"yaak-cli\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[[bin]]\nname = \"yaak\"\npath = \"src/main.rs\"\n\n[dependencies]\narboard = \"3\"\nbase64 = \"0.22\"\nclap = { version = \"4\", features = [\"derive\"] }\nconsole = \"0.15\"\ndirs = \"6\"\nenv_logger = \"0.11\"\nfutures = \"0.3\"\ninquire = { version = \"0.7\", features = [\"editor\"] }\nhex = { workspace = true }\ninclude_dir = \"0.7\"\nkeyring = { workspace = true, features = [\"apple-native\", \"windows-native\", \"sync-secret-service\"] }\nlog = { workspace = true }\nrand = \"0.8\"\nreqwest = { workspace = true }\nrolldown = \"0.1.0\"\noxc_resolver = \"=11.10.0\"\nschemars = { workspace = true }\nserde = { workspace = true }\nserde_json = { workspace = true }\nsha2 = { workspace = true }\ntokio = { workspace = true, features = [\n  \"rt-multi-thread\",\n  \"macros\",\n  \"io-util\",\n  \"net\",\n  \"signal\",\n  \"time\",\n] }\nwalkdir = \"2\"\nwebbrowser = \"1\"\nzip = \"4\"\nyaak = { workspace = true }\nyaak-api = { workspace = true }\nyaak-crypto = { workspace = true }\nyaak-http = { workspace = true }\nyaak-models = { workspace = true }\nyaak-plugins = { workspace = true }\nyaak-templates = { workspace = true }\n\n[dev-dependencies]\nassert_cmd = \"2\"\npredicates = \"3\"\ntempfile = \"3\"\n"
  },
  {
    "path": "crates-cli/yaak-cli/README.md",
    "content": "# Yaak CLI\n\nThe `yaak` CLI for publishing plugins and creating/updating/sending requests.\n\n## Installation\n\n```sh\nnpm install @yaakapp/cli\n```\n\n## Agentic Workflows\n\nThe `yaak` CLI is primarily meant to be used by AI agents, and has the following features:\n\n- `schema` subcommands to get the JSON Schema for any model (eg. `yaak request schema http`)\n- `--json '{...}'` input format to create and update data\n- `--verbose` mode for extracting debug info while sending requests\n- The ability to send entire workspaces and folders (Supports `--parallel` and `--fail-fast`)\n\n### Example Prompts\n\nUse the `yaak` CLI with agents like Claude or Codex to do useful things for you.\n\nHere are some example prompts:\n\n```text\nScan my API routes and create a workspace (using yaak cli) with\nall the requests needed for me to do manual testing?\n```\n\n```text\nSend all the GraphQL requests in my workspace\n```\n\n## Description\n\nHere's the current print of `yaak --help`\n\n```text\nYaak CLI - API client from the command line\n\nUsage: yaak [OPTIONS] <COMMAND>\n\nCommands:\n  auth         Authentication commands\n  plugin       Plugin development and publishing commands\n  send         Send a request, folder, or workspace by ID\n  workspace    Workspace commands\n  request      Request commands\n  folder       Folder commands\n  environment  Environment commands\n\nOptions:\n      --data-dir <DATA_DIR>        Use a custom data directory\n  -e, --environment <ENVIRONMENT>  Environment ID to use for variable substitution\n  -v, --verbose                    Enable verbose send output (events and streamed response body)\n      --log [<LEVEL>]              Enable CLI logging; optionally set level (error|warn|info|debug|trace) [possible values: error, warn, info, debug, trace]\n  -h, --help                       Print help\n  -V, --version                    Print version\n\nAgent Hints:\n  - Template variable syntax is ${[ my_var ]}, not {{ ... }}\n  - Template function syntax is ${[ namespace.my_func(a='aaa',b='bbb') ]}\n  - View JSONSchema for models before creating or updating (eg. `yaak request schema http`)\n  - Deletion requires confirmation (--yes for non-interactive environments)\n```\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/cli.rs",
    "content": "use clap::{Args, Parser, Subcommand, ValueEnum};\nuse std::path::PathBuf;\n\n#[derive(Parser)]\n#[command(name = \"yaak\")]\n#[command(about = \"Yaak CLI - API client from the command line\")]\n#[command(version = crate::version::cli_version())]\n#[command(disable_help_subcommand = true)]\n#[command(after_help = r#\"Agent Hints:\n  - Template variable syntax is ${[ my_var ]}, not {{ ... }}\n  - Template function syntax is ${[ namespace.my_func(a='aaa',b='bbb') ]}\n  - View JSONSchema for models before creating or updating (eg. `yaak request schema http`)\n  - Deletion requires confirmation (--yes for non-interactive environments)\n  \"#)]\npub struct Cli {\n    /// Use a custom data directory\n    #[arg(long, global = true)]\n    pub data_dir: Option<PathBuf>,\n\n    /// Environment ID to use for variable substitution\n    #[arg(long, short, global = true)]\n    pub environment: Option<String>,\n\n    /// Cookie jar ID to use when sending requests\n    #[arg(long = \"cookie-jar\", global = true, value_name = \"COOKIE_JAR_ID\")]\n    pub cookie_jar: Option<String>,\n\n    /// Enable verbose send output (events and streamed response body)\n    #[arg(long, short, global = true)]\n    pub verbose: bool,\n\n    /// Enable CLI logging; optionally set level (error|warn|info|debug|trace)\n    #[arg(long, global = true, value_name = \"LEVEL\", num_args = 0..=1, ignore_case = true)]\n    pub log: Option<Option<LogLevel>>,\n\n    #[command(subcommand)]\n    pub command: Commands,\n}\n\n#[derive(Subcommand)]\npub enum Commands {\n    /// Authentication commands\n    Auth(AuthArgs),\n\n    /// Plugin development and publishing commands\n    Plugin(PluginArgs),\n\n    #[command(hide = true)]\n    Build(PluginPathArg),\n\n    #[command(hide = true)]\n    Dev(PluginPathArg),\n\n    /// Backward-compatible alias for `plugin generate`\n    #[command(hide = true)]\n    Generate(GenerateArgs),\n\n    /// Backward-compatible alias for `plugin publish`\n    #[command(hide = true)]\n    Publish(PluginPathArg),\n\n    /// Send a request, folder, or workspace by ID\n    Send(SendArgs),\n\n    /// Cookie jar commands\n    CookieJar(CookieJarArgs),\n\n    /// Workspace commands\n    Workspace(WorkspaceArgs),\n\n    /// Request commands\n    Request(RequestArgs),\n\n    /// Folder commands\n    Folder(FolderArgs),\n\n    /// Environment commands\n    Environment(EnvironmentArgs),\n}\n\n#[derive(Args)]\npub struct SendArgs {\n    /// Request, folder, or workspace ID\n    pub id: String,\n\n    /// Execute requests in parallel\n    #[arg(long)]\n    pub parallel: bool,\n\n    /// Stop on first request failure when sending folders/workspaces\n    #[arg(long, conflicts_with = \"parallel\")]\n    pub fail_fast: bool,\n}\n\n#[derive(Args)]\n#[command(disable_help_subcommand = true)]\npub struct CookieJarArgs {\n    #[command(subcommand)]\n    pub command: CookieJarCommands,\n}\n\n#[derive(Subcommand)]\npub enum CookieJarCommands {\n    /// List cookie jars in a workspace\n    List {\n        /// Workspace ID (optional when exactly one workspace exists)\n        workspace_id: Option<String>,\n    },\n}\n\n#[derive(Args)]\n#[command(disable_help_subcommand = true)]\npub struct WorkspaceArgs {\n    #[command(subcommand)]\n    pub command: WorkspaceCommands,\n}\n\n#[derive(Subcommand)]\npub enum WorkspaceCommands {\n    /// List all workspaces\n    List,\n\n    /// Output JSON schema for workspace create/update payloads\n    Schema {\n        /// Pretty-print schema JSON output\n        #[arg(long)]\n        pretty: bool,\n    },\n\n    /// Show a workspace as JSON\n    Show {\n        /// Workspace ID\n        workspace_id: String,\n    },\n\n    /// Create a workspace\n    Create {\n        /// Workspace name\n        #[arg(short, long)]\n        name: Option<String>,\n\n        /// JSON payload\n        #[arg(long, conflicts_with = \"json_input\")]\n        json: Option<String>,\n\n        /// JSON payload shorthand\n        #[arg(value_name = \"JSON\", conflicts_with = \"json\")]\n        json_input: Option<String>,\n    },\n\n    /// Update a workspace\n    Update {\n        /// JSON payload\n        #[arg(long, conflicts_with = \"json_input\")]\n        json: Option<String>,\n\n        /// JSON payload shorthand\n        #[arg(value_name = \"JSON\", conflicts_with = \"json\")]\n        json_input: Option<String>,\n    },\n\n    /// Delete a workspace\n    Delete {\n        /// Workspace ID\n        workspace_id: String,\n\n        /// Skip confirmation prompt\n        #[arg(short, long)]\n        yes: bool,\n    },\n}\n\n#[derive(Args)]\n#[command(disable_help_subcommand = true)]\npub struct RequestArgs {\n    #[command(subcommand)]\n    pub command: RequestCommands,\n}\n\n#[derive(Subcommand)]\npub enum RequestCommands {\n    /// List requests in a workspace\n    List {\n        /// Workspace ID (optional when exactly one workspace exists)\n        workspace_id: Option<String>,\n    },\n\n    /// Show a request as JSON\n    Show {\n        /// Request ID\n        request_id: String,\n    },\n\n    /// Send a request by ID\n    Send {\n        /// Request ID\n        request_id: String,\n    },\n\n    /// Output JSON schema for request create/update payloads\n    Schema {\n        #[arg(value_enum)]\n        request_type: RequestSchemaType,\n\n        /// Pretty-print schema JSON output\n        #[arg(long)]\n        pretty: bool,\n    },\n\n    /// Create a new HTTP request\n    Create {\n        /// Workspace ID (or positional JSON payload shorthand)\n        workspace_id: Option<String>,\n\n        /// Request name\n        #[arg(short, long)]\n        name: Option<String>,\n\n        /// HTTP method\n        #[arg(short, long)]\n        method: Option<String>,\n\n        /// URL\n        #[arg(short, long)]\n        url: Option<String>,\n\n        /// JSON payload\n        #[arg(long)]\n        json: Option<String>,\n    },\n\n    /// Update an HTTP request\n    Update {\n        /// JSON payload\n        #[arg(long, conflicts_with = \"json_input\")]\n        json: Option<String>,\n\n        /// JSON payload shorthand\n        #[arg(value_name = \"JSON\", conflicts_with = \"json\")]\n        json_input: Option<String>,\n    },\n\n    /// Delete a request\n    Delete {\n        /// Request ID\n        request_id: String,\n\n        /// Skip confirmation prompt\n        #[arg(short, long)]\n        yes: bool,\n    },\n}\n\n#[derive(Clone, Copy, Debug, ValueEnum)]\npub enum RequestSchemaType {\n    Http,\n    Grpc,\n    Websocket,\n}\n\n#[derive(Clone, Copy, Debug, ValueEnum)]\npub enum LogLevel {\n    Error,\n    Warn,\n    Info,\n    Debug,\n    Trace,\n}\n\nimpl LogLevel {\n    pub fn as_filter(self) -> log::LevelFilter {\n        match self {\n            LogLevel::Error => log::LevelFilter::Error,\n            LogLevel::Warn => log::LevelFilter::Warn,\n            LogLevel::Info => log::LevelFilter::Info,\n            LogLevel::Debug => log::LevelFilter::Debug,\n            LogLevel::Trace => log::LevelFilter::Trace,\n        }\n    }\n}\n\n#[derive(Args)]\n#[command(disable_help_subcommand = true)]\npub struct FolderArgs {\n    #[command(subcommand)]\n    pub command: FolderCommands,\n}\n\n#[derive(Subcommand)]\npub enum FolderCommands {\n    /// List folders in a workspace\n    List {\n        /// Workspace ID (optional when exactly one workspace exists)\n        workspace_id: Option<String>,\n    },\n\n    /// Show a folder as JSON\n    Show {\n        /// Folder ID\n        folder_id: String,\n    },\n\n    /// Create a folder\n    Create {\n        /// Workspace ID (or positional JSON payload shorthand)\n        workspace_id: Option<String>,\n\n        /// Folder name\n        #[arg(short, long)]\n        name: Option<String>,\n\n        /// JSON payload\n        #[arg(long)]\n        json: Option<String>,\n    },\n\n    /// Update a folder\n    Update {\n        /// JSON payload\n        #[arg(long, conflicts_with = \"json_input\")]\n        json: Option<String>,\n\n        /// JSON payload shorthand\n        #[arg(value_name = \"JSON\", conflicts_with = \"json\")]\n        json_input: Option<String>,\n    },\n\n    /// Delete a folder\n    Delete {\n        /// Folder ID\n        folder_id: String,\n\n        /// Skip confirmation prompt\n        #[arg(short, long)]\n        yes: bool,\n    },\n}\n\n#[derive(Args)]\n#[command(disable_help_subcommand = true)]\npub struct EnvironmentArgs {\n    #[command(subcommand)]\n    pub command: EnvironmentCommands,\n}\n\n#[derive(Subcommand)]\npub enum EnvironmentCommands {\n    /// List environments in a workspace\n    List {\n        /// Workspace ID (optional when exactly one workspace exists)\n        workspace_id: Option<String>,\n    },\n\n    /// Output JSON schema for environment create/update payloads\n    Schema {\n        /// Pretty-print schema JSON output\n        #[arg(long)]\n        pretty: bool,\n    },\n\n    /// Show an environment as JSON\n    Show {\n        /// Environment ID\n        environment_id: String,\n    },\n\n    /// Create an environment\n    #[command(after_help = r#\"Modes (choose one):\n  1) yaak environment create <workspace_id> --name <name>\n  2) yaak environment create --json '{\"workspaceId\":\"wk_abc\",\"name\":\"Production\"}'\n  3) yaak environment create '{\"workspaceId\":\"wk_abc\",\"name\":\"Production\"}'\n  4) yaak environment create <workspace_id> --json '{\"name\":\"Production\"}'\n\"#)]\n    Create {\n        /// Workspace ID for flag-based mode, or positional JSON payload shorthand\n        #[arg(value_name = \"WORKSPACE_ID_OR_JSON\")]\n        workspace_id: Option<String>,\n\n        /// Environment name\n        #[arg(short, long)]\n        name: Option<String>,\n\n        /// JSON payload (use instead of WORKSPACE_ID/--name)\n        #[arg(long)]\n        json: Option<String>,\n    },\n\n    /// Update an environment\n    Update {\n        /// JSON payload\n        #[arg(long, conflicts_with = \"json_input\")]\n        json: Option<String>,\n\n        /// JSON payload shorthand\n        #[arg(value_name = \"JSON\", conflicts_with = \"json\")]\n        json_input: Option<String>,\n    },\n\n    /// Delete an environment\n    Delete {\n        /// Environment ID\n        environment_id: String,\n\n        /// Skip confirmation prompt\n        #[arg(short, long)]\n        yes: bool,\n    },\n}\n\n#[derive(Args)]\n#[command(disable_help_subcommand = true)]\npub struct AuthArgs {\n    #[command(subcommand)]\n    pub command: AuthCommands,\n}\n\n#[derive(Subcommand)]\npub enum AuthCommands {\n    /// Login to Yaak via web browser\n    Login,\n\n    /// Sign out of the Yaak CLI\n    Logout,\n\n    /// Print the current logged-in user's info\n    Whoami,\n}\n\n#[derive(Args)]\n#[command(disable_help_subcommand = true)]\npub struct PluginArgs {\n    #[command(subcommand)]\n    pub command: PluginCommands,\n}\n\n#[derive(Subcommand)]\npub enum PluginCommands {\n    /// Transpile code into a runnable plugin bundle\n    Build(PluginPathArg),\n\n    /// Build plugin bundle continuously when the filesystem changes\n    Dev(PluginPathArg),\n\n    /// Generate a \"Hello World\" Yaak plugin\n    Generate(GenerateArgs),\n\n    /// Install a plugin from a local directory or from the registry\n    Install(InstallPluginArgs),\n\n    /// Publish a Yaak plugin version to the plugin registry\n    Publish(PluginPathArg),\n}\n\n#[derive(Args, Clone)]\npub struct PluginPathArg {\n    /// Path to plugin directory (defaults to current working directory)\n    pub path: Option<PathBuf>,\n}\n\n#[derive(Args, Clone)]\npub struct GenerateArgs {\n    /// Plugin name (defaults to a generated name in interactive mode)\n    #[arg(long)]\n    pub name: Option<String>,\n\n    /// Output directory for the generated plugin (defaults to ./<name> in interactive mode)\n    #[arg(long)]\n    pub dir: Option<PathBuf>,\n}\n\n#[derive(Args, Clone)]\npub struct InstallPluginArgs {\n    /// Local plugin directory path, or registry plugin spec (@org/plugin[@version])\n    pub source: String,\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/commands/auth.rs",
    "content": "use crate::cli::{AuthArgs, AuthCommands};\nuse crate::ui;\nuse crate::utils::http;\nuse base64::Engine as _;\nuse keyring::Entry;\nuse rand::RngCore;\nuse rand::rngs::OsRng;\nuse reqwest::Url;\nuse serde_json::Value;\nuse sha2::{Digest, Sha256};\nuse std::io::{self, IsTerminal, Write};\nuse std::time::Duration;\nuse tokio::io::{AsyncReadExt, AsyncWriteExt};\nuse tokio::net::{TcpListener, TcpStream};\n\nconst OAUTH_CLIENT_ID: &str = \"a1fe44800c2d7e803cad1b4bf07a291c\";\nconst KEYRING_USER: &str = \"yaak\";\nconst AUTH_TIMEOUT: Duration = Duration::from_secs(300);\nconst MAX_REQUEST_BYTES: usize = 16 * 1024;\n\ntype CommandResult<T = ()> = std::result::Result<T, String>;\n\n#[derive(Clone, Copy, Debug, Eq, PartialEq)]\nenum Environment {\n    Production,\n    Staging,\n    Development,\n}\n\nimpl Environment {\n    fn app_base_url(self) -> &'static str {\n        match self {\n            Environment::Production => \"https://yaak.app\",\n            Environment::Staging => \"https://todo.yaak.app\",\n            Environment::Development => \"http://localhost:9444\",\n        }\n    }\n\n    fn api_base_url(self) -> &'static str {\n        match self {\n            Environment::Production => \"https://api.yaak.app\",\n            Environment::Staging => \"https://todo.yaak.app\",\n            Environment::Development => \"http://localhost:9444\",\n        }\n    }\n\n    fn keyring_service(self) -> &'static str {\n        match self {\n            Environment::Production => \"app.yaak.cli.Token\",\n            Environment::Staging => \"app.yaak.cli.staging.Token\",\n            Environment::Development => \"app.yaak.cli.dev.Token\",\n        }\n    }\n}\n\nstruct OAuthFlow {\n    app_base_url: String,\n    auth_url: Url,\n    token_url: String,\n    redirect_url: String,\n    state: String,\n    code_verifier: String,\n}\n\npub async fn run(args: AuthArgs) -> i32 {\n    let result = match args.command {\n        AuthCommands::Login => login().await,\n        AuthCommands::Logout => logout(),\n        AuthCommands::Whoami => whoami().await,\n    };\n\n    match result {\n        Ok(()) => 0,\n        Err(error) => {\n            ui::error(&error);\n            1\n        }\n    }\n}\n\nasync fn login() -> CommandResult {\n    let environment = current_environment();\n\n    let listener = TcpListener::bind(\"127.0.0.1:0\")\n        .await\n        .map_err(|e| format!(\"Failed to start OAuth callback server: {e}\"))?;\n    let port = listener\n        .local_addr()\n        .map_err(|e| format!(\"Failed to determine callback server port: {e}\"))?\n        .port();\n\n    let oauth = build_oauth_flow(environment, port)?;\n\n    ui::info(&format!(\"Initiating login to {}\", oauth.auth_url));\n    if !confirm_open_browser()? {\n        ui::info(\"Login canceled\");\n        return Ok(());\n    }\n\n    if let Err(err) = webbrowser::open(oauth.auth_url.as_ref()) {\n        ui::warning(&format!(\"Failed to open browser: {err}\"));\n        ui::info(&format!(\"Open this URL manually:\\n{}\", oauth.auth_url));\n    }\n    ui::info(\"Waiting for authentication...\");\n\n    let code = tokio::select! {\n        result = receive_oauth_code(listener, &oauth.state, &oauth.app_base_url) => result?,\n        _ = tokio::signal::ctrl_c() => {\n            return Err(\"Interrupted by user\".to_string());\n        }\n        _ = tokio::time::sleep(AUTH_TIMEOUT) => {\n            return Err(\"Timeout waiting for authentication\".to_string());\n        }\n    };\n\n    let token = exchange_access_token(&oauth, &code).await?;\n    store_auth_token(environment, &token)?;\n    ui::success(\"Authentication successful!\");\n    Ok(())\n}\n\nfn logout() -> CommandResult {\n    delete_auth_token(current_environment())?;\n    ui::success(\"Signed out of Yaak\");\n    Ok(())\n}\n\nasync fn whoami() -> CommandResult {\n    let environment = current_environment();\n    let token = match get_auth_token(environment)? {\n        Some(token) => token,\n        None => {\n            ui::warning(\"Not logged in\");\n            ui::info(\"Please run `yaak auth login`\");\n            return Ok(());\n        }\n    };\n\n    let url = format!(\"{}/api/v1/whoami\", environment.api_base_url());\n    let response = http::build_client(Some(&token))?\n        .get(url)\n        .send()\n        .await\n        .map_err(|e| format!(\"Failed to call whoami endpoint: {e}\"))?;\n\n    let status = response.status();\n    let body =\n        response.text().await.map_err(|e| format!(\"Failed to read whoami response body: {e}\"))?;\n\n    if !status.is_success() {\n        if status.as_u16() == 401 {\n            let _ = delete_auth_token(environment);\n            return Err(\n                \"Unauthorized to access CLI. Run `yaak auth login` to refresh credentials.\"\n                    .to_string(),\n            );\n        }\n        return Err(http::parse_api_error(status.as_u16(), &body));\n    }\n\n    println!(\"{body}\");\n    Ok(())\n}\n\nfn current_environment() -> Environment {\n    let value = std::env::var(\"ENVIRONMENT\").ok();\n    parse_environment(value.as_deref())\n}\n\nfn parse_environment(value: Option<&str>) -> Environment {\n    match value {\n        Some(\"staging\") => Environment::Staging,\n        Some(\"development\") => Environment::Development,\n        _ => Environment::Production,\n    }\n}\n\nfn build_oauth_flow(environment: Environment, callback_port: u16) -> CommandResult<OAuthFlow> {\n    let code_verifier = random_hex(32);\n    let state = random_hex(24);\n    let redirect_url = format!(\"http://127.0.0.1:{callback_port}/oauth/callback\");\n\n    let code_challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD\n        .encode(Sha256::digest(code_verifier.as_bytes()));\n\n    let mut auth_url = Url::parse(&format!(\"{}/login/oauth/authorize\", environment.app_base_url()))\n        .map_err(|e| format!(\"Failed to build OAuth authorize URL: {e}\"))?;\n    auth_url\n        .query_pairs_mut()\n        .append_pair(\"response_type\", \"code\")\n        .append_pair(\"client_id\", OAUTH_CLIENT_ID)\n        .append_pair(\"redirect_uri\", &redirect_url)\n        .append_pair(\"state\", &state)\n        .append_pair(\"code_challenge_method\", \"S256\")\n        .append_pair(\"code_challenge\", &code_challenge);\n\n    Ok(OAuthFlow {\n        app_base_url: environment.app_base_url().to_string(),\n        auth_url,\n        token_url: format!(\"{}/login/oauth/access_token\", environment.app_base_url()),\n        redirect_url,\n        state,\n        code_verifier,\n    })\n}\n\nasync fn receive_oauth_code(\n    listener: TcpListener,\n    expected_state: &str,\n    app_base_url: &str,\n) -> CommandResult<String> {\n    loop {\n        let (mut stream, _) = listener\n            .accept()\n            .await\n            .map_err(|e| format!(\"OAuth callback server accept error: {e}\"))?;\n\n        match parse_callback_request(&mut stream).await {\n            Ok((state, code)) => {\n                if state != expected_state {\n                    let _ = write_bad_request(&mut stream, \"Invalid OAuth state\").await;\n                    continue;\n                }\n\n                let success_redirect = format!(\"{app_base_url}/login/oauth/success\");\n                write_redirect(&mut stream, &success_redirect)\n                    .await\n                    .map_err(|e| format!(\"Failed responding to OAuth callback: {e}\"))?;\n                return Ok(code);\n            }\n            Err(error) => {\n                let _ = write_bad_request(&mut stream, &error).await;\n                if error.starts_with(\"OAuth provider returned error:\") {\n                    return Err(error);\n                }\n            }\n        }\n    }\n}\n\nasync fn parse_callback_request(stream: &mut TcpStream) -> CommandResult<(String, String)> {\n    let target = read_http_target(stream).await?;\n    if !target.starts_with(\"/oauth/callback\") {\n        return Err(\"Expected /oauth/callback path\".to_string());\n    }\n\n    let url = Url::parse(&format!(\"http://127.0.0.1{target}\"))\n        .map_err(|e| format!(\"Failed to parse callback URL: {e}\"))?;\n    let mut state: Option<String> = None;\n    let mut code: Option<String> = None;\n    let mut oauth_error: Option<String> = None;\n    let mut oauth_error_description: Option<String> = None;\n\n    for (k, v) in url.query_pairs() {\n        if k == \"state\" {\n            state = Some(v.into_owned());\n        } else if k == \"code\" {\n            code = Some(v.into_owned());\n        } else if k == \"error\" {\n            oauth_error = Some(v.into_owned());\n        } else if k == \"error_description\" {\n            oauth_error_description = Some(v.into_owned());\n        }\n    }\n\n    if let Some(error) = oauth_error {\n        let mut message = format!(\"OAuth provider returned error: {error}\");\n        if let Some(description) = oauth_error_description.filter(|d| !d.is_empty()) {\n            message.push_str(&format!(\" ({description})\"));\n        }\n        return Err(message);\n    }\n\n    let state = state.ok_or_else(|| \"Missing 'state' query parameter\".to_string())?;\n    let code = code.ok_or_else(|| \"Missing 'code' query parameter\".to_string())?;\n\n    if code.is_empty() {\n        return Err(\"Missing 'code' query parameter\".to_string());\n    }\n\n    Ok((state, code))\n}\n\nasync fn read_http_target(stream: &mut TcpStream) -> CommandResult<String> {\n    let mut buf = vec![0_u8; MAX_REQUEST_BYTES];\n    let mut total_read = 0_usize;\n\n    loop {\n        let n = stream\n            .read(&mut buf[total_read..])\n            .await\n            .map_err(|e| format!(\"Failed reading callback request: {e}\"))?;\n        if n == 0 {\n            break;\n        }\n        total_read += n;\n\n        if buf[..total_read].windows(4).any(|w| w == b\"\\r\\n\\r\\n\") {\n            break;\n        }\n\n        if total_read == MAX_REQUEST_BYTES {\n            return Err(\"OAuth callback request too large\".to_string());\n        }\n    }\n\n    let req = String::from_utf8_lossy(&buf[..total_read]);\n    let request_line =\n        req.lines().next().ok_or_else(|| \"Invalid callback request line\".to_string())?;\n    let mut parts = request_line.split_whitespace();\n    let method = parts.next().unwrap_or_default();\n    let target = parts.next().unwrap_or_default();\n\n    if method != \"GET\" {\n        return Err(format!(\"Expected GET callback request, got '{method}'\"));\n    }\n    if target.is_empty() {\n        return Err(\"Missing callback request target\".to_string());\n    }\n\n    Ok(target.to_string())\n}\n\nasync fn write_bad_request(stream: &mut TcpStream, message: &str) -> std::io::Result<()> {\n    let body = format!(\"Failed to authenticate: {message}\");\n    let response = format!(\n        \"HTTP/1.1 400 Bad Request\\r\\nContent-Type: text/plain; charset=utf-8\\r\\nContent-Length: {}\\r\\nConnection: close\\r\\n\\r\\n{}\",\n        body.len(),\n        body\n    );\n    stream.write_all(response.as_bytes()).await?;\n    stream.shutdown().await\n}\n\nasync fn write_redirect(stream: &mut TcpStream, location: &str) -> std::io::Result<()> {\n    let response = format!(\n        \"HTTP/1.1 302 Found\\r\\nLocation: {location}\\r\\nContent-Length: 0\\r\\nConnection: close\\r\\n\\r\\n\"\n    );\n    stream.write_all(response.as_bytes()).await?;\n    stream.shutdown().await\n}\n\nasync fn exchange_access_token(oauth: &OAuthFlow, code: &str) -> CommandResult<String> {\n    let response = http::build_client(None)?\n        .post(&oauth.token_url)\n        .form(&[\n            (\"grant_type\", \"authorization_code\"),\n            (\"client_id\", OAUTH_CLIENT_ID),\n            (\"code\", code),\n            (\"redirect_uri\", oauth.redirect_url.as_str()),\n            (\"code_verifier\", oauth.code_verifier.as_str()),\n        ])\n        .send()\n        .await\n        .map_err(|e| format!(\"Failed to exchange OAuth code for access token: {e}\"))?;\n\n    let status = response.status();\n    let body =\n        response.text().await.map_err(|e| format!(\"Failed to read token response body: {e}\"))?;\n\n    if !status.is_success() {\n        return Err(format!(\n            \"Failed to fetch access token: status={} body={}\",\n            status.as_u16(),\n            body\n        ));\n    }\n\n    let parsed: Value =\n        serde_json::from_str(&body).map_err(|e| format!(\"Invalid token response JSON: {e}\"))?;\n    let token = parsed\n        .get(\"access_token\")\n        .and_then(Value::as_str)\n        .filter(|s| !s.is_empty())\n        .ok_or_else(|| format!(\"Token response missing access_token: {body}\"))?;\n\n    Ok(token.to_string())\n}\n\nfn keyring_entry(environment: Environment) -> CommandResult<Entry> {\n    Entry::new(environment.keyring_service(), KEYRING_USER)\n        .map_err(|e| format!(\"Failed to initialize auth keyring entry: {e}\"))\n}\n\nfn get_auth_token(environment: Environment) -> CommandResult<Option<String>> {\n    let entry = keyring_entry(environment)?;\n    match entry.get_password() {\n        Ok(token) => Ok(Some(token)),\n        Err(keyring::Error::NoEntry) => Ok(None),\n        Err(err) => Err(format!(\"Failed to read auth token: {err}\")),\n    }\n}\n\nfn store_auth_token(environment: Environment, token: &str) -> CommandResult {\n    let entry = keyring_entry(environment)?;\n    entry.set_password(token).map_err(|e| format!(\"Failed to store auth token: {e}\"))\n}\n\nfn delete_auth_token(environment: Environment) -> CommandResult {\n    let entry = keyring_entry(environment)?;\n    match entry.delete_credential() {\n        Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),\n        Err(err) => Err(format!(\"Failed to delete auth token: {err}\")),\n    }\n}\n\nfn random_hex(bytes: usize) -> String {\n    let mut data = vec![0_u8; bytes];\n    OsRng.fill_bytes(&mut data);\n    hex::encode(data)\n}\n\nfn confirm_open_browser() -> CommandResult<bool> {\n    if !io::stdin().is_terminal() {\n        return Ok(true);\n    }\n\n    loop {\n        print!(\"Open default browser? [Y/n]: \");\n        io::stdout().flush().map_err(|e| format!(\"Failed to flush stdout: {e}\"))?;\n\n        let mut input = String::new();\n        io::stdin().read_line(&mut input).map_err(|e| format!(\"Failed to read input: {e}\"))?;\n\n        match input.trim().to_ascii_lowercase().as_str() {\n            \"\" | \"y\" | \"yes\" => return Ok(true),\n            \"n\" | \"no\" => return Ok(false),\n            _ => ui::warning(\"Please answer y or n\"),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn environment_mapping() {\n        assert_eq!(parse_environment(Some(\"staging\")), Environment::Staging);\n        assert_eq!(parse_environment(Some(\"development\")), Environment::Development);\n        assert_eq!(parse_environment(Some(\"production\")), Environment::Production);\n        assert_eq!(parse_environment(None), Environment::Production);\n    }\n\n    #[tokio::test]\n    async fn parses_callback_request() {\n        let listener = TcpListener::bind(\"127.0.0.1:0\").await.expect(\"bind\");\n        let addr = listener.local_addr().expect(\"local addr\");\n\n        let server = tokio::spawn(async move {\n            let (mut stream, _) = listener.accept().await.expect(\"accept\");\n            parse_callback_request(&mut stream).await\n        });\n\n        let mut client = TcpStream::connect(addr).await.expect(\"connect\");\n        client\n            .write_all(\n                b\"GET /oauth/callback?code=abc123&state=xyz HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n\",\n            )\n            .await\n            .expect(\"write\");\n\n        let parsed = server.await.expect(\"join\").expect(\"parse\");\n        assert_eq!(parsed.0, \"xyz\");\n        assert_eq!(parsed.1, \"abc123\");\n    }\n\n    #[tokio::test]\n    async fn parse_callback_request_oauth_error() {\n        let listener = TcpListener::bind(\"127.0.0.1:0\").await.expect(\"bind\");\n        let addr = listener.local_addr().expect(\"local addr\");\n\n        let server = tokio::spawn(async move {\n            let (mut stream, _) = listener.accept().await.expect(\"accept\");\n            parse_callback_request(&mut stream).await\n        });\n\n        let mut client = TcpStream::connect(addr).await.expect(\"connect\");\n        client\n            .write_all(\n                b\"GET /oauth/callback?error=access_denied&error_description=User%20denied&state=xyz HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n\",\n            )\n            .await\n            .expect(\"write\");\n\n        let err = server.await.expect(\"join\").expect_err(\"should fail\");\n        assert!(err.contains(\"OAuth provider returned error: access_denied\"));\n        assert!(err.contains(\"User denied\"));\n    }\n\n    #[tokio::test]\n    async fn receive_oauth_code_fails_fast_on_provider_error() {\n        let listener = TcpListener::bind(\"127.0.0.1:0\").await.expect(\"bind\");\n        let addr = listener.local_addr().expect(\"local addr\");\n\n        let server = tokio::spawn(async move {\n            receive_oauth_code(listener, \"expected-state\", \"http://localhost:9444\").await\n        });\n\n        let mut client = TcpStream::connect(addr).await.expect(\"connect\");\n        client\n            .write_all(\n                b\"GET /oauth/callback?error=access_denied&state=expected-state HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n\",\n            )\n            .await\n            .expect(\"write\");\n\n        let result = tokio::time::timeout(std::time::Duration::from_secs(2), server)\n            .await\n            .expect(\"should not timeout\")\n            .expect(\"join\");\n        let err = result.expect_err(\"should return oauth error\");\n        assert!(err.contains(\"OAuth provider returned error: access_denied\"));\n    }\n\n    #[test]\n    fn builds_oauth_flow_with_pkce() {\n        let flow = build_oauth_flow(Environment::Development, 8080).expect(\"flow\");\n        assert!(flow.auth_url.as_str().contains(\"code_challenge_method=S256\"));\n        assert!(\n            flow.auth_url\n                .as_str()\n                .contains(\"redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Foauth%2Fcallback\")\n        );\n        assert_eq!(flow.redirect_url, \"http://127.0.0.1:8080/oauth/callback\");\n        assert_eq!(flow.token_url, \"http://localhost:9444/login/oauth/access_token\");\n    }\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/commands/cookie_jar.rs",
    "content": "use crate::cli::{CookieJarArgs, CookieJarCommands};\nuse crate::context::CliContext;\nuse crate::utils::workspace::resolve_workspace_id;\n\ntype CommandResult<T = ()> = std::result::Result<T, String>;\n\npub fn run(ctx: &CliContext, args: CookieJarArgs) -> i32 {\n    let result = match args.command {\n        CookieJarCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()),\n    };\n\n    match result {\n        Ok(()) => 0,\n        Err(error) => {\n            eprintln!(\"Error: {error}\");\n            1\n        }\n    }\n}\n\nfn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {\n    let workspace_id = resolve_workspace_id(ctx, workspace_id, \"cookie-jar list\")?;\n    let cookie_jars = ctx\n        .db()\n        .list_cookie_jars(&workspace_id)\n        .map_err(|e| format!(\"Failed to list cookie jars: {e}\"))?;\n\n    if cookie_jars.is_empty() {\n        println!(\"No cookie jars found in workspace {}\", workspace_id);\n    } else {\n        for cookie_jar in cookie_jars {\n            println!(\n                \"{} - {} ({} cookies)\",\n                cookie_jar.id,\n                cookie_jar.name,\n                cookie_jar.cookies.len()\n            );\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/commands/environment.rs",
    "content": "use crate::cli::{EnvironmentArgs, EnvironmentCommands};\nuse crate::context::CliContext;\nuse crate::utils::confirm::confirm_delete;\nuse crate::utils::json::{\n    apply_merge_patch, is_json_shorthand, merge_workspace_id_arg, parse_optional_json,\n    parse_required_json, require_id, validate_create_id,\n};\nuse crate::utils::schema::append_agent_hints;\nuse crate::utils::workspace::resolve_workspace_id;\nuse schemars::schema_for;\nuse yaak_models::models::Environment;\nuse yaak_models::util::UpdateSource;\n\ntype CommandResult<T = ()> = std::result::Result<T, String>;\n\npub fn run(ctx: &CliContext, args: EnvironmentArgs) -> i32 {\n    let result = match args.command {\n        EnvironmentCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()),\n        EnvironmentCommands::Schema { pretty } => schema(pretty),\n        EnvironmentCommands::Show { environment_id } => show(ctx, &environment_id),\n        EnvironmentCommands::Create { workspace_id, name, json } => {\n            create(ctx, workspace_id, name, json)\n        }\n        EnvironmentCommands::Update { json, json_input } => update(ctx, json, json_input),\n        EnvironmentCommands::Delete { environment_id, yes } => delete(ctx, &environment_id, yes),\n    };\n\n    match result {\n        Ok(()) => 0,\n        Err(error) => {\n            eprintln!(\"Error: {error}\");\n            1\n        }\n    }\n}\n\nfn schema(pretty: bool) -> CommandResult {\n    let mut schema = serde_json::to_value(schema_for!(Environment))\n        .map_err(|e| format!(\"Failed to serialize environment schema: {e}\"))?;\n    append_agent_hints(&mut schema);\n\n    let output =\n        if pretty { serde_json::to_string_pretty(&schema) } else { serde_json::to_string(&schema) }\n            .map_err(|e| format!(\"Failed to format environment schema JSON: {e}\"))?;\n    println!(\"{output}\");\n    Ok(())\n}\n\nfn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {\n    let workspace_id = resolve_workspace_id(ctx, workspace_id, \"environment list\")?;\n    let environments = ctx\n        .db()\n        .list_environments_ensure_base(&workspace_id)\n        .map_err(|e| format!(\"Failed to list environments: {e}\"))?;\n\n    if environments.is_empty() {\n        println!(\"No environments found in workspace {}\", workspace_id);\n    } else {\n        for environment in environments {\n            println!(\"{} - {} ({})\", environment.id, environment.name, environment.parent_model);\n        }\n    }\n    Ok(())\n}\n\nfn show(ctx: &CliContext, environment_id: &str) -> CommandResult {\n    let environment = ctx\n        .db()\n        .get_environment(environment_id)\n        .map_err(|e| format!(\"Failed to get environment: {e}\"))?;\n    let output = serde_json::to_string_pretty(&environment)\n        .map_err(|e| format!(\"Failed to serialize environment: {e}\"))?;\n    println!(\"{output}\");\n    Ok(())\n}\n\nfn create(\n    ctx: &CliContext,\n    workspace_id: Option<String>,\n    name: Option<String>,\n    json: Option<String>,\n) -> CommandResult {\n    let json_shorthand =\n        workspace_id.as_deref().filter(|v| is_json_shorthand(v)).map(str::to_owned);\n    let workspace_id_arg = workspace_id.filter(|v| !is_json_shorthand(v));\n\n    let payload = parse_optional_json(json, json_shorthand, \"environment create\")?;\n\n    if let Some(payload) = payload {\n        if name.is_some() {\n            return Err(\"environment create cannot combine --name with JSON payload\".to_string());\n        }\n\n        validate_create_id(&payload, \"environment\")?;\n        let mut environment: Environment = serde_json::from_value(payload)\n            .map_err(|e| format!(\"Failed to parse environment create JSON: {e}\"))?;\n        let fallback_workspace_id =\n            if workspace_id_arg.is_none() && environment.workspace_id.is_empty() {\n                Some(resolve_workspace_id(ctx, None, \"environment create\")?)\n            } else {\n                None\n            };\n        merge_workspace_id_arg(\n            workspace_id_arg.as_deref().or(fallback_workspace_id.as_deref()),\n            &mut environment.workspace_id,\n            \"environment create\",\n        )?;\n\n        if environment.parent_model.is_empty() {\n            environment.parent_model = \"environment\".to_string();\n        }\n\n        let created = ctx\n            .db()\n            .upsert_environment(&environment, &UpdateSource::Sync)\n            .map_err(|e| format!(\"Failed to create environment: {e}\"))?;\n\n        println!(\"Created environment: {}\", created.id);\n        return Ok(());\n    }\n\n    let workspace_id =\n        resolve_workspace_id(ctx, workspace_id_arg.as_deref(), \"environment create\")?;\n    let name = name.ok_or_else(|| {\n        \"environment create requires --name unless JSON payload is provided\".to_string()\n    })?;\n\n    let environment = Environment {\n        workspace_id,\n        name,\n        parent_model: \"environment\".to_string(),\n        ..Default::default()\n    };\n\n    let created = ctx\n        .db()\n        .upsert_environment(&environment, &UpdateSource::Sync)\n        .map_err(|e| format!(\"Failed to create environment: {e}\"))?;\n\n    println!(\"Created environment: {}\", created.id);\n    Ok(())\n}\n\nfn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {\n    let patch = parse_required_json(json, json_input, \"environment update\")?;\n    let id = require_id(&patch, \"environment update\")?;\n\n    let existing = ctx\n        .db()\n        .get_environment(&id)\n        .map_err(|e| format!(\"Failed to get environment for update: {e}\"))?;\n    let updated = apply_merge_patch(&existing, &patch, &id, \"environment update\")?;\n\n    let saved = ctx\n        .db()\n        .upsert_environment(&updated, &UpdateSource::Sync)\n        .map_err(|e| format!(\"Failed to update environment: {e}\"))?;\n\n    println!(\"Updated environment: {}\", saved.id);\n    Ok(())\n}\n\nfn delete(ctx: &CliContext, environment_id: &str, yes: bool) -> CommandResult {\n    if !yes && !confirm_delete(\"environment\", environment_id) {\n        println!(\"Aborted\");\n        return Ok(());\n    }\n\n    let deleted = ctx\n        .db()\n        .delete_environment_by_id(environment_id, &UpdateSource::Sync)\n        .map_err(|e| format!(\"Failed to delete environment: {e}\"))?;\n\n    println!(\"Deleted environment: {}\", deleted.id);\n    Ok(())\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/commands/folder.rs",
    "content": "use crate::cli::{FolderArgs, FolderCommands};\nuse crate::context::CliContext;\nuse crate::utils::confirm::confirm_delete;\nuse crate::utils::json::{\n    apply_merge_patch, is_json_shorthand, merge_workspace_id_arg, parse_optional_json,\n    parse_required_json, require_id, validate_create_id,\n};\nuse crate::utils::workspace::resolve_workspace_id;\nuse yaak_models::models::Folder;\nuse yaak_models::util::UpdateSource;\n\ntype CommandResult<T = ()> = std::result::Result<T, String>;\n\npub fn run(ctx: &CliContext, args: FolderArgs) -> i32 {\n    let result = match args.command {\n        FolderCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()),\n        FolderCommands::Show { folder_id } => show(ctx, &folder_id),\n        FolderCommands::Create { workspace_id, name, json } => {\n            create(ctx, workspace_id, name, json)\n        }\n        FolderCommands::Update { json, json_input } => update(ctx, json, json_input),\n        FolderCommands::Delete { folder_id, yes } => delete(ctx, &folder_id, yes),\n    };\n\n    match result {\n        Ok(()) => 0,\n        Err(error) => {\n            eprintln!(\"Error: {error}\");\n            1\n        }\n    }\n}\n\nfn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {\n    let workspace_id = resolve_workspace_id(ctx, workspace_id, \"folder list\")?;\n    let folders =\n        ctx.db().list_folders(&workspace_id).map_err(|e| format!(\"Failed to list folders: {e}\"))?;\n    if folders.is_empty() {\n        println!(\"No folders found in workspace {}\", workspace_id);\n    } else {\n        for folder in folders {\n            println!(\"{} - {}\", folder.id, folder.name);\n        }\n    }\n    Ok(())\n}\n\nfn show(ctx: &CliContext, folder_id: &str) -> CommandResult {\n    let folder =\n        ctx.db().get_folder(folder_id).map_err(|e| format!(\"Failed to get folder: {e}\"))?;\n    let output = serde_json::to_string_pretty(&folder)\n        .map_err(|e| format!(\"Failed to serialize folder: {e}\"))?;\n    println!(\"{output}\");\n    Ok(())\n}\n\nfn create(\n    ctx: &CliContext,\n    workspace_id: Option<String>,\n    name: Option<String>,\n    json: Option<String>,\n) -> CommandResult {\n    let json_shorthand =\n        workspace_id.as_deref().filter(|v| is_json_shorthand(v)).map(str::to_owned);\n    let workspace_id_arg = workspace_id.filter(|v| !is_json_shorthand(v));\n\n    let payload = parse_optional_json(json, json_shorthand, \"folder create\")?;\n\n    if let Some(payload) = payload {\n        if name.is_some() {\n            return Err(\"folder create cannot combine --name with JSON payload\".to_string());\n        }\n\n        validate_create_id(&payload, \"folder\")?;\n        let mut folder: Folder = serde_json::from_value(payload)\n            .map_err(|e| format!(\"Failed to parse folder create JSON: {e}\"))?;\n        let fallback_workspace_id = if workspace_id_arg.is_none() && folder.workspace_id.is_empty()\n        {\n            Some(resolve_workspace_id(ctx, None, \"folder create\")?)\n        } else {\n            None\n        };\n        merge_workspace_id_arg(\n            workspace_id_arg.as_deref().or(fallback_workspace_id.as_deref()),\n            &mut folder.workspace_id,\n            \"folder create\",\n        )?;\n\n        let created = ctx\n            .db()\n            .upsert_folder(&folder, &UpdateSource::Sync)\n            .map_err(|e| format!(\"Failed to create folder: {e}\"))?;\n\n        println!(\"Created folder: {}\", created.id);\n        return Ok(());\n    }\n\n    let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), \"folder create\")?;\n    let name = name.ok_or_else(|| {\n        \"folder create requires --name unless JSON payload is provided\".to_string()\n    })?;\n\n    let folder = Folder { workspace_id, name, ..Default::default() };\n\n    let created = ctx\n        .db()\n        .upsert_folder(&folder, &UpdateSource::Sync)\n        .map_err(|e| format!(\"Failed to create folder: {e}\"))?;\n\n    println!(\"Created folder: {}\", created.id);\n    Ok(())\n}\n\nfn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {\n    let patch = parse_required_json(json, json_input, \"folder update\")?;\n    let id = require_id(&patch, \"folder update\")?;\n\n    let existing =\n        ctx.db().get_folder(&id).map_err(|e| format!(\"Failed to get folder for update: {e}\"))?;\n    let updated = apply_merge_patch(&existing, &patch, &id, \"folder update\")?;\n\n    let saved = ctx\n        .db()\n        .upsert_folder(&updated, &UpdateSource::Sync)\n        .map_err(|e| format!(\"Failed to update folder: {e}\"))?;\n\n    println!(\"Updated folder: {}\", saved.id);\n    Ok(())\n}\n\nfn delete(ctx: &CliContext, folder_id: &str, yes: bool) -> CommandResult {\n    if !yes && !confirm_delete(\"folder\", folder_id) {\n        println!(\"Aborted\");\n        return Ok(());\n    }\n\n    let deleted = ctx\n        .db()\n        .delete_folder_by_id(folder_id, &UpdateSource::Sync)\n        .map_err(|e| format!(\"Failed to delete folder: {e}\"))?;\n\n    println!(\"Deleted folder: {}\", deleted.id);\n    Ok(())\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/commands/mod.rs",
    "content": "pub mod auth;\npub mod cookie_jar;\npub mod environment;\npub mod folder;\npub mod plugin;\npub mod request;\npub mod send;\npub mod workspace;\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/commands/plugin.rs",
    "content": "use crate::cli::{GenerateArgs, InstallPluginArgs, PluginPathArg};\nuse crate::context::CliContext;\nuse crate::ui;\nuse crate::utils::http;\nuse keyring::Entry;\nuse rand::Rng;\nuse rolldown::{\n    BundleEvent, Bundler, BundlerOptions, ExperimentalOptions, InputItem, LogLevel, OutputFormat,\n    Platform, WatchOption, Watcher, WatcherEvent,\n};\nuse serde::Deserialize;\nuse std::collections::HashSet;\nuse std::fs;\nuse std::io::{self, IsTerminal, Read, Write};\nuse std::path::{Path, PathBuf};\nuse std::sync::Arc;\nuse tokio::sync::Mutex;\nuse walkdir::WalkDir;\nuse yaak_api::{ApiClientKind, yaak_api_client};\nuse yaak_models::models::{Plugin, PluginSource};\nuse yaak_models::util::UpdateSource;\nuse yaak_plugins::events::PluginContext;\nuse yaak_plugins::install::download_and_install;\nuse zip::CompressionMethod;\nuse zip::write::SimpleFileOptions;\n\ntype CommandResult<T = ()> = std::result::Result<T, String>;\n\nconst KEYRING_USER: &str = \"yaak\";\n\n#[derive(Clone, Copy, Debug, Eq, PartialEq)]\nenum Environment {\n    Production,\n    Staging,\n    Development,\n}\n\nimpl Environment {\n    fn api_base_url(self) -> &'static str {\n        match self {\n            Environment::Production => \"https://api.yaak.app\",\n            Environment::Staging => \"https://todo.yaak.app\",\n            Environment::Development => \"http://localhost:9444\",\n        }\n    }\n\n    fn keyring_service(self) -> &'static str {\n        match self {\n            Environment::Production => \"app.yaak.cli.Token\",\n            Environment::Staging => \"app.yaak.cli.staging.Token\",\n            Environment::Development => \"app.yaak.cli.dev.Token\",\n        }\n    }\n}\n\npub async fn run_build(args: PluginPathArg) -> i32 {\n    match build(args).await {\n        Ok(()) => 0,\n        Err(error) => {\n            ui::error(&error);\n            1\n        }\n    }\n}\n\npub async fn run_install(context: &CliContext, args: InstallPluginArgs) -> i32 {\n    match install(context, args).await {\n        Ok(()) => 0,\n        Err(error) => {\n            ui::error(&error);\n            1\n        }\n    }\n}\n\npub async fn run_dev(args: PluginPathArg) -> i32 {\n    match dev(args).await {\n        Ok(()) => 0,\n        Err(error) => {\n            ui::error(&error);\n            1\n        }\n    }\n}\n\npub async fn run_generate(args: GenerateArgs) -> i32 {\n    match generate(args) {\n        Ok(()) => 0,\n        Err(error) => {\n            ui::error(&error);\n            1\n        }\n    }\n}\n\npub async fn run_publish(args: PluginPathArg) -> i32 {\n    match publish(args).await {\n        Ok(()) => 0,\n        Err(error) => {\n            ui::error(&error);\n            1\n        }\n    }\n}\n\nasync fn build(args: PluginPathArg) -> CommandResult {\n    let plugin_dir = resolve_plugin_dir(args.path)?;\n    ensure_plugin_build_inputs(&plugin_dir)?;\n\n    ui::info(&format!(\"Building plugin {}...\", plugin_dir.display()));\n    let warnings = build_plugin_bundle(&plugin_dir).await?;\n    for warning in warnings {\n        ui::warning(&warning);\n    }\n    ui::success(&format!(\"Built plugin bundle at {}\", plugin_dir.join(\"build/index.js\").display()));\n    Ok(())\n}\n\nasync fn dev(args: PluginPathArg) -> CommandResult {\n    let plugin_dir = resolve_plugin_dir(args.path)?;\n    ensure_plugin_build_inputs(&plugin_dir)?;\n\n    ui::info(&format!(\"Watching plugin {}...\", plugin_dir.display()));\n\n    let bundler = Bundler::new(bundler_options(&plugin_dir, true))\n        .map_err(|err| format!(\"Failed to initialize Rolldown watcher: {err}\"))?;\n    let watcher = Watcher::new(vec![Arc::new(Mutex::new(bundler))], None)\n        .map_err(|err| format!(\"Failed to start Rolldown watcher: {err}\"))?;\n    let emitter = watcher.emitter();\n    let watch_root = plugin_dir.clone();\n    let _event_logger = tokio::spawn(async move {\n        loop {\n            let event = {\n                let rx = emitter.rx.lock().await;\n                rx.recv()\n            };\n\n            let Ok(event) = event else {\n                break;\n            };\n\n            match event {\n                WatcherEvent::Change(change) => {\n                    let changed_path = Path::new(change.path.as_str());\n                    let display_path = changed_path\n                        .strip_prefix(&watch_root)\n                        .map(|p| p.display().to_string())\n                        .unwrap_or_else(|_| {\n                            changed_path\n                                .file_name()\n                                .map(|name| name.to_string_lossy().into_owned())\n                                .unwrap_or_else(|| \"unknown\".to_string())\n                        });\n                    ui::info(&format!(\"Rebuilding plugin {display_path}\"));\n                }\n                WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {}\n                WatcherEvent::Event(BundleEvent::Error(event)) => {\n                    if event.error.diagnostics.is_empty() {\n                        ui::error(\"Plugin build failed\");\n                    } else {\n                        for diagnostic in event.error.diagnostics {\n                            ui::error(&diagnostic.to_string());\n                        }\n                    }\n                }\n                WatcherEvent::Close => break,\n                _ => {}\n            }\n        }\n    });\n\n    watcher.start().await;\n    Ok(())\n}\n\nfn generate(args: GenerateArgs) -> CommandResult {\n    let default_name = random_name();\n    let name = match args.name {\n        Some(name) => name,\n        None => prompt_with_default(\"Plugin name\", &default_name)?,\n    };\n\n    let default_dir = format!(\"./{name}\");\n    let output_dir = match args.dir {\n        Some(dir) => dir,\n        None => PathBuf::from(prompt_with_default(\"Plugin dir\", &default_dir)?),\n    };\n\n    if output_dir.exists() {\n        return Err(format!(\"Plugin directory already exists: {}\", output_dir.display()));\n    }\n\n    ui::info(&format!(\"Generating plugin in {}\", output_dir.display()));\n    fs::create_dir_all(output_dir.join(\"src\"))\n        .map_err(|e| format!(\"Failed creating plugin directory {}: {e}\", output_dir.display()))?;\n\n    write_file(&output_dir.join(\".gitignore\"), TEMPLATE_GITIGNORE)?;\n    write_file(\n        &output_dir.join(\"package.json\"),\n        &TEMPLATE_PACKAGE_JSON.replace(\"yaak-plugin-name\", &name),\n    )?;\n    write_file(&output_dir.join(\"tsconfig.json\"), TEMPLATE_TSCONFIG)?;\n    write_file(&output_dir.join(\"README.md\"), &TEMPLATE_README.replace(\"yaak-plugin-name\", &name))?;\n    write_file(\n        &output_dir.join(\"src/index.ts\"),\n        &TEMPLATE_INDEX_TS.replace(\"yaak-plugin-name\", &name),\n    )?;\n    write_file(&output_dir.join(\"src/index.test.ts\"), TEMPLATE_INDEX_TEST_TS)?;\n\n    ui::success(\"Plugin scaffold generated\");\n    ui::info(\"Next steps:\");\n    println!(\"  1. cd {}\", output_dir.display());\n    println!(\"  2. npm install\");\n    println!(\"  3. yaak plugin build\");\n    Ok(())\n}\n\nasync fn publish(args: PluginPathArg) -> CommandResult {\n    let plugin_dir = resolve_plugin_dir(args.path)?;\n    ensure_plugin_build_inputs(&plugin_dir)?;\n\n    let environment = current_environment();\n    let token = get_auth_token(environment)?\n        .ok_or_else(|| \"Not logged in. Run `yaak auth login`.\".to_string())?;\n\n    ui::info(&format!(\"Building plugin {}...\", plugin_dir.display()));\n    let warnings = build_plugin_bundle(&plugin_dir).await?;\n    for warning in warnings {\n        ui::warning(&warning);\n    }\n\n    ui::info(\"Archiving plugin\");\n    let archive = create_publish_archive(&plugin_dir)?;\n\n    ui::info(\"Uploading plugin\");\n    let url = format!(\"{}/api/v1/plugins/publish\", environment.api_base_url());\n    let response = http::build_client(Some(&token))?\n        .post(url)\n        .header(reqwest::header::CONTENT_TYPE, \"application/zip\")\n        .body(archive)\n        .send()\n        .await\n        .map_err(|e| format!(\"Failed to upload plugin: {e}\"))?;\n\n    let status = response.status();\n    let body =\n        response.text().await.map_err(|e| format!(\"Failed reading publish response body: {e}\"))?;\n\n    if !status.is_success() {\n        return Err(http::parse_api_error(status.as_u16(), &body));\n    }\n\n    let published: PublishResponse = serde_json::from_str(&body)\n        .map_err(|e| format!(\"Failed parsing publish response JSON: {e}\\nResponse: {body}\"))?;\n    ui::success(&format!(\"Plugin published {}\", published.version));\n    println!(\" -> {}\", published.url);\n    Ok(())\n}\n\nasync fn install(context: &CliContext, args: InstallPluginArgs) -> CommandResult {\n    if args.source.starts_with('@') {\n        let (name, version) =\n            parse_registry_install_spec(args.source.as_str()).ok_or_else(|| {\n                \"Invalid registry plugin spec. Expected format: @org/plugin or @org/plugin@version\"\n                    .to_string()\n            })?;\n        return install_from_registry(context, name, version).await;\n    }\n\n    install_from_directory(context, args.source.as_str()).await\n}\n\nasync fn install_from_registry(\n    context: &CliContext,\n    name: String,\n    version: Option<String>,\n) -> CommandResult {\n    let current_version = crate::version::cli_version();\n    let http_client = yaak_api_client(ApiClientKind::Cli, current_version)\n        .map_err(|err| format!(\"Failed to initialize API client: {err}\"))?;\n    let installing_version = version.clone().unwrap_or_else(|| \"latest\".to_string());\n    ui::info(&format!(\"Installing registry plugin {name}@{installing_version}\"));\n\n    let plugin_context = PluginContext::new(Some(\"cli\".to_string()), None);\n    let installed = download_and_install(\n        context.plugin_manager(),\n        context.query_manager(),\n        &http_client,\n        &plugin_context,\n        name.as_str(),\n        version,\n    )\n    .await\n    .map_err(|err| format!(\"Failed to install plugin: {err}\"))?;\n\n    ui::success(&format!(\"Installed plugin {}@{}\", installed.name, installed.version));\n    Ok(())\n}\n\nasync fn install_from_directory(context: &CliContext, source: &str) -> CommandResult {\n    let plugin_dir = resolve_plugin_dir(Some(PathBuf::from(source)))?;\n    let plugin_dir_str = plugin_dir\n        .to_str()\n        .ok_or_else(|| {\n            format!(\"Plugin directory path is not valid UTF-8: {}\", plugin_dir.display())\n        })?\n        .to_string();\n    ui::info(&format!(\"Installing plugin from directory {}\", plugin_dir.display()));\n\n    let plugin = context\n        .db()\n        .upsert_plugin(\n            &Plugin {\n                directory: plugin_dir_str,\n                url: None,\n                enabled: true,\n                source: PluginSource::Filesystem,\n                ..Default::default()\n            },\n            &UpdateSource::Background,\n        )\n        .map_err(|err| format!(\"Failed to save plugin in database: {err}\"))?;\n\n    let plugin_context = PluginContext::new(Some(\"cli\".to_string()), None);\n    context\n        .plugin_manager()\n        .add_plugin(&plugin_context, &plugin)\n        .await\n        .map_err(|err| format!(\"Failed to load plugin runtime: {err}\"))?;\n\n    ui::success(&format!(\"Installed plugin from {}\", plugin.directory));\n    Ok(())\n}\n\nfn parse_registry_install_spec(source: &str) -> Option<(String, Option<String>)> {\n    if !source.starts_with('@') || !source.contains('/') {\n        return None;\n    }\n\n    let rest = source.get(1..)?;\n    let version_split = rest.rfind('@').map(|idx| idx + 1);\n    let (name, version) = match version_split {\n        Some(at_idx) => {\n            let (name, version) = source.split_at(at_idx);\n            let version = version.strip_prefix('@').unwrap_or_default();\n            if version.is_empty() {\n                return None;\n            }\n            (name.to_string(), Some(version.to_string()))\n        }\n        None => (source.to_string(), None),\n    };\n\n    if !name.starts_with('@') {\n        return None;\n    }\n\n    let without_scope = name.get(1..)?;\n    let (scope, plugin_name) = without_scope.split_once('/')?;\n    if scope.is_empty() || plugin_name.is_empty() {\n        return None;\n    }\n\n    Some((name, version))\n}\n\n#[derive(Deserialize)]\nstruct PublishResponse {\n    version: String,\n    url: String,\n}\n\nasync fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {\n    prepare_build_output_dir(plugin_dir)?;\n    let mut bundler = Bundler::new(bundler_options(plugin_dir, false))\n        .map_err(|err| format!(\"Failed to initialize Rolldown: {err}\"))?;\n    let output = bundler.write().await.map_err(|err| format!(\"Plugin build failed:\\n{err}\"))?;\n\n    Ok(output.warnings.into_iter().map(|w| w.to_string()).collect())\n}\n\nfn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {\n    let build_dir = plugin_dir.join(\"build\");\n    if build_dir.exists() {\n        fs::remove_dir_all(&build_dir)\n            .map_err(|e| format!(\"Failed to clean build directory {}: {e}\", build_dir.display()))?;\n    }\n    fs::create_dir_all(&build_dir)\n        .map_err(|e| format!(\"Failed to create build directory {}: {e}\", build_dir.display()))\n}\n\nfn bundler_options(plugin_dir: &Path, watch: bool) -> BundlerOptions {\n    BundlerOptions {\n        input: Some(vec![InputItem { import: \"./src/index.ts\".to_string(), ..Default::default() }]),\n        cwd: Some(plugin_dir.to_path_buf()),\n        file: Some(\"build/index.js\".to_string()),\n        format: Some(OutputFormat::Cjs),\n        platform: Some(Platform::Node),\n        log_level: Some(LogLevel::Info),\n        experimental: watch\n            .then_some(ExperimentalOptions { incremental_build: Some(true), ..Default::default() }),\n        watch: watch.then_some(WatchOption::default()),\n        ..Default::default()\n    }\n}\n\nfn resolve_plugin_dir(path: Option<PathBuf>) -> CommandResult<PathBuf> {\n    let cwd =\n        std::env::current_dir().map_err(|e| format!(\"Failed to read current directory: {e}\"))?;\n    let candidate = match path {\n        Some(path) if path.is_absolute() => path,\n        Some(path) => cwd.join(path),\n        None => cwd,\n    };\n\n    if !candidate.exists() {\n        return Err(format!(\"Plugin directory does not exist: {}\", candidate.display()));\n    }\n    if !candidate.is_dir() {\n        return Err(format!(\"Plugin path is not a directory: {}\", candidate.display()));\n    }\n\n    candidate\n        .canonicalize()\n        .map_err(|e| format!(\"Failed to resolve plugin directory {}: {e}\", candidate.display()))\n}\n\nfn ensure_plugin_build_inputs(plugin_dir: &Path) -> CommandResult {\n    let package_json = plugin_dir.join(\"package.json\");\n    if !package_json.is_file() {\n        return Err(format!(\n            \"{} does not exist. Ensure that you are in a plugin directory.\",\n            package_json.display()\n        ));\n    }\n\n    let entry = plugin_dir.join(\"src/index.ts\");\n    if !entry.is_file() {\n        return Err(format!(\"Required entrypoint missing: {}\", entry.display()));\n    }\n\n    Ok(())\n}\n\nfn create_publish_archive(plugin_dir: &Path) -> CommandResult<Vec<u8>> {\n    let required_files = [\n        \"README.md\",\n        \"package.json\",\n        \"build/index.js\",\n        \"src/index.ts\",\n    ];\n    let optional_files = [\"package-lock.json\"];\n\n    let mut selected = HashSet::new();\n    for required in required_files {\n        let required_path = plugin_dir.join(required);\n        if !required_path.is_file() {\n            return Err(format!(\"Missing required file: {required}\"));\n        }\n        selected.insert(required.to_string());\n    }\n    for optional in optional_files {\n        selected.insert(optional.to_string());\n    }\n\n    let cursor = std::io::Cursor::new(Vec::new());\n    let mut zip = zip::ZipWriter::new(cursor);\n    let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);\n\n    for entry in WalkDir::new(plugin_dir) {\n        let entry = entry.map_err(|e| format!(\"Failed walking plugin directory: {e}\"))?;\n        if !entry.file_type().is_file() {\n            continue;\n        }\n\n        let path = entry.path();\n        let rel = path\n            .strip_prefix(plugin_dir)\n            .map_err(|e| format!(\"Failed deriving relative path for {}: {e}\", path.display()))?;\n        let rel = rel.to_string_lossy().replace('\\\\', \"/\");\n\n        let keep = rel.starts_with(\"src/\") || rel.starts_with(\"build/\") || selected.contains(&rel);\n        if !keep {\n            continue;\n        }\n\n        zip.start_file(rel, options).map_err(|e| format!(\"Failed adding file to archive: {e}\"))?;\n        let mut file = fs::File::open(path)\n            .map_err(|e| format!(\"Failed opening file {}: {e}\", path.display()))?;\n        let mut contents = Vec::new();\n        file.read_to_end(&mut contents)\n            .map_err(|e| format!(\"Failed reading file {}: {e}\", path.display()))?;\n        zip.write_all(&contents).map_err(|e| format!(\"Failed writing archive contents: {e}\"))?;\n    }\n\n    let cursor = zip.finish().map_err(|e| format!(\"Failed finalizing plugin archive: {e}\"))?;\n    Ok(cursor.into_inner())\n}\n\nfn write_file(path: &Path, contents: &str) -> CommandResult {\n    if let Some(parent) = path.parent() {\n        fs::create_dir_all(parent)\n            .map_err(|e| format!(\"Failed creating directory {}: {e}\", parent.display()))?;\n    }\n    fs::write(path, contents).map_err(|e| format!(\"Failed writing file {}: {e}\", path.display()))\n}\n\nfn prompt_with_default(label: &str, default: &str) -> CommandResult<String> {\n    if !io::stdin().is_terminal() {\n        return Ok(default.to_string());\n    }\n\n    print!(\"{label} [{default}]: \");\n    io::stdout().flush().map_err(|e| format!(\"Failed to flush stdout: {e}\"))?;\n\n    let mut input = String::new();\n    io::stdin().read_line(&mut input).map_err(|e| format!(\"Failed to read input: {e}\"))?;\n    let trimmed = input.trim();\n\n    if trimmed.is_empty() { Ok(default.to_string()) } else { Ok(trimmed.to_string()) }\n}\n\nfn current_environment() -> Environment {\n    match std::env::var(\"ENVIRONMENT\").as_deref() {\n        Ok(\"staging\") => Environment::Staging,\n        Ok(\"development\") => Environment::Development,\n        _ => Environment::Production,\n    }\n}\n\nfn keyring_entry(environment: Environment) -> CommandResult<Entry> {\n    Entry::new(environment.keyring_service(), KEYRING_USER)\n        .map_err(|e| format!(\"Failed to initialize auth keyring entry: {e}\"))\n}\n\nfn get_auth_token(environment: Environment) -> CommandResult<Option<String>> {\n    let entry = keyring_entry(environment)?;\n    match entry.get_password() {\n        Ok(token) => Ok(Some(token)),\n        Err(keyring::Error::NoEntry) => Ok(None),\n        Err(err) => Err(format!(\"Failed to read auth token: {err}\")),\n    }\n}\n\nfn random_name() -> String {\n    const ADJECTIVES: &[&str] = &[\n        \"young\", \"youthful\", \"yellow\", \"yielding\", \"yappy\", \"yawning\", \"yummy\", \"yucky\", \"yearly\",\n        \"yester\", \"yeasty\", \"yelling\",\n    ];\n    const NOUNS: &[&str] = &[\n        \"yak\", \"yarn\", \"year\", \"yell\", \"yoke\", \"yoga\", \"yam\", \"yacht\", \"yodel\",\n    ];\n\n    let mut rng = rand::thread_rng();\n    let adjective = ADJECTIVES[rng.gen_range(0..ADJECTIVES.len())];\n    let noun = NOUNS[rng.gen_range(0..NOUNS.len())];\n    format!(\"{adjective}-{noun}\")\n}\n\nconst TEMPLATE_GITIGNORE: &str = \"node_modules\\n\";\n\nconst TEMPLATE_PACKAGE_JSON: &str = r#\"{\n  \"name\": \"yaak-plugin-name\",\n  \"private\": true,\n  \"version\": \"0.0.1\",\n  \"scripts\": {\n    \"build\": \"yaak plugin build\",\n    \"dev\": \"yaak plugin dev\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^24.10.1\",\n    \"typescript\": \"^5.9.3\",\n    \"vitest\": \"^4.0.14\"\n  },\n  \"dependencies\": {\n    \"@yaakapp/api\": \"^0.7.0\"\n  }\n}\n\"#;\n\nconst TEMPLATE_TSCONFIG: &str = r#\"{\n  \"compilerOptions\": {\n    \"target\": \"es2021\",\n    \"lib\": [\"DOM\", \"DOM.Iterable\", \"ESNext\"],\n    \"useDefineForClassFields\": true,\n    \"allowJs\": false,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": false,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Node\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\"\n  },\n  \"include\": [\"src\"]\n}\n\"#;\n\nconst TEMPLATE_README: &str = r#\"# yaak-plugin-name\n\nDescribe what your plugin does.\n\"#;\n\nconst TEMPLATE_INDEX_TS: &str = r#\"import type { PluginDefinition } from \"@yaakapp/api\";\n\nexport const plugin: PluginDefinition = {\n  httpRequestActions: [\n    {\n      label: \"Hello, From Plugin\",\n      icon: \"info\",\n      async onSelect(ctx, args) {\n        await ctx.toast.show({\n          color: \"success\",\n          message: `You clicked the request ${args.httpRequest.id}`,\n        });\n      },\n    },\n  ],\n};\n\"#;\n\nconst TEMPLATE_INDEX_TEST_TS: &str = r#\"import { describe, expect, test } from \"vitest\";\nimport { plugin } from \"./index\";\n\ndescribe(\"Example Plugin\", () => {\n  test(\"Exports plugin object\", () => {\n    expect(plugin).toBeTypeOf(\"object\");\n  });\n});\n\"#;\n\n#[cfg(test)]\nmod tests {\n    use super::create_publish_archive;\n    use std::collections::HashSet;\n    use std::fs;\n    use std::io::Cursor;\n    use tempfile::TempDir;\n    use zip::ZipArchive;\n\n    #[test]\n    fn publish_archive_includes_required_and_optional_files() {\n        let dir = TempDir::new().expect(\"temp dir\");\n        let root = dir.path();\n\n        fs::create_dir_all(root.join(\"src\")).expect(\"create src\");\n        fs::create_dir_all(root.join(\"build\")).expect(\"create build\");\n        fs::create_dir_all(root.join(\"ignored\")).expect(\"create ignored\");\n\n        fs::write(root.join(\"README.md\"), \"# Demo\\n\").expect(\"write README\");\n        fs::write(root.join(\"package.json\"), \"{}\").expect(\"write package.json\");\n        fs::write(root.join(\"package-lock.json\"), \"{}\").expect(\"write package-lock.json\");\n        fs::write(root.join(\"src/index.ts\"), \"export const plugin = {};\\n\")\n            .expect(\"write src/index.ts\");\n        fs::write(root.join(\"build/index.js\"), \"exports.plugin = {};\\n\")\n            .expect(\"write build/index.js\");\n        fs::write(root.join(\"ignored/secret.txt\"), \"do-not-ship\").expect(\"write ignored file\");\n\n        let archive = create_publish_archive(root).expect(\"create archive\");\n        let mut zip = ZipArchive::new(Cursor::new(archive)).expect(\"open zip\");\n\n        let mut names = HashSet::new();\n        for i in 0..zip.len() {\n            let file = zip.by_index(i).expect(\"zip entry\");\n            names.insert(file.name().to_string());\n        }\n\n        assert!(names.contains(\"README.md\"));\n        assert!(names.contains(\"package.json\"));\n        assert!(names.contains(\"package-lock.json\"));\n        assert!(names.contains(\"src/index.ts\"));\n        assert!(names.contains(\"build/index.js\"));\n        assert!(!names.contains(\"ignored/secret.txt\"));\n    }\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/commands/request.rs",
    "content": "use crate::cli::{RequestArgs, RequestCommands, RequestSchemaType};\nuse crate::context::CliContext;\nuse crate::utils::confirm::confirm_delete;\nuse crate::utils::json::{\n    apply_merge_patch, is_json_shorthand, merge_workspace_id_arg, parse_optional_json,\n    parse_required_json, require_id, validate_create_id,\n};\nuse crate::utils::schema::append_agent_hints;\nuse crate::utils::workspace::resolve_workspace_id;\nuse schemars::schema_for;\nuse serde_json::{Map, Value, json};\nuse std::collections::HashMap;\nuse std::io::Write;\nuse tokio::sync::mpsc;\nuse yaak::send::{SendHttpRequestByIdWithPluginsParams, send_http_request_by_id_with_plugins};\nuse yaak_http::sender::HttpResponseEvent as SenderHttpResponseEvent;\nuse yaak_models::models::{GrpcRequest, HttpRequest, WebsocketRequest};\nuse yaak_models::queries::any_request::AnyRequest;\nuse yaak_models::util::UpdateSource;\nuse yaak_plugins::events::{FormInput, FormInputBase, JsonPrimitive, PluginContext};\n\ntype CommandResult<T = ()> = std::result::Result<T, String>;\n\npub async fn run(\n    ctx: &CliContext,\n    args: RequestArgs,\n    environment: Option<&str>,\n    cookie_jar_id: Option<&str>,\n    verbose: bool,\n) -> i32 {\n    let result = match args.command {\n        RequestCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()),\n        RequestCommands::Show { request_id } => show(ctx, &request_id),\n        RequestCommands::Send { request_id } => {\n            return match send_request_by_id(ctx, &request_id, environment, cookie_jar_id, verbose)\n                .await\n            {\n                Ok(()) => 0,\n                Err(error) => {\n                    eprintln!(\"Error: {error}\");\n                    1\n                }\n            };\n        }\n        RequestCommands::Schema { request_type, pretty } => {\n            return match schema(ctx, request_type, pretty).await {\n                Ok(()) => 0,\n                Err(error) => {\n                    eprintln!(\"Error: {error}\");\n                    1\n                }\n            };\n        }\n        RequestCommands::Create { workspace_id, name, method, url, json } => {\n            create(ctx, workspace_id, name, method, url, json)\n        }\n        RequestCommands::Update { json, json_input } => update(ctx, json, json_input),\n        RequestCommands::Delete { request_id, yes } => delete(ctx, &request_id, yes),\n    };\n\n    match result {\n        Ok(()) => 0,\n        Err(error) => {\n            eprintln!(\"Error: {error}\");\n            1\n        }\n    }\n}\n\nfn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {\n    let workspace_id = resolve_workspace_id(ctx, workspace_id, \"request list\")?;\n    let requests = ctx\n        .db()\n        .list_http_requests(&workspace_id)\n        .map_err(|e| format!(\"Failed to list requests: {e}\"))?;\n    if requests.is_empty() {\n        println!(\"No requests found in workspace {}\", workspace_id);\n    } else {\n        for request in requests {\n            println!(\"{} - {} {}\", request.id, request.method, request.name);\n        }\n    }\n    Ok(())\n}\n\nasync fn schema(ctx: &CliContext, request_type: RequestSchemaType, pretty: bool) -> CommandResult {\n    let mut schema = match request_type {\n        RequestSchemaType::Http => serde_json::to_value(schema_for!(HttpRequest))\n            .map_err(|e| format!(\"Failed to serialize HTTP request schema: {e}\"))?,\n        RequestSchemaType::Grpc => serde_json::to_value(schema_for!(GrpcRequest))\n            .map_err(|e| format!(\"Failed to serialize gRPC request schema: {e}\"))?,\n        RequestSchemaType::Websocket => serde_json::to_value(schema_for!(WebsocketRequest))\n            .map_err(|e| format!(\"Failed to serialize WebSocket request schema: {e}\"))?,\n    };\n\n    enrich_schema_guidance(&mut schema, request_type);\n    append_agent_hints(&mut schema);\n\n    if let Err(error) = merge_auth_schema_from_plugins(ctx, &mut schema).await {\n        eprintln!(\"Warning: Failed to enrich authentication schema from plugins: {error}\");\n    }\n\n    let output =\n        if pretty { serde_json::to_string_pretty(&schema) } else { serde_json::to_string(&schema) }\n            .map_err(|e| format!(\"Failed to format schema JSON: {e}\"))?;\n    println!(\"{output}\");\n    Ok(())\n}\n\nfn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) {\n    if !matches!(request_type, RequestSchemaType::Http) {\n        return;\n    }\n\n    let Some(properties) = schema.get_mut(\"properties\").and_then(Value::as_object_mut) else {\n        return;\n    };\n\n    if let Some(url_schema) = properties.get_mut(\"url\").and_then(Value::as_object_mut) {\n        append_description(\n            url_schema,\n            \"For path segments like `/foo/:id/comments/:commentId`, put concrete values in `urlParameters` using names without `:` (for example `id`, `commentId`).\",\n        );\n    }\n}\n\nfn append_description(schema: &mut Map<String, Value>, extra: &str) {\n    match schema.get_mut(\"description\") {\n        Some(Value::String(existing)) if !existing.trim().is_empty() => {\n            if !existing.ends_with(' ') {\n                existing.push(' ');\n            }\n            existing.push_str(extra);\n        }\n        _ => {\n            schema.insert(\"description\".to_string(), Value::String(extra.to_string()));\n        }\n    }\n}\n\nasync fn merge_auth_schema_from_plugins(\n    ctx: &CliContext,\n    schema: &mut Value,\n) -> Result<(), String> {\n    let plugin_context = PluginContext::new_empty();\n    let plugin_manager = ctx.plugin_manager();\n    let summaries = plugin_manager\n        .get_http_authentication_summaries(&plugin_context)\n        .await\n        .map_err(|e| e.to_string())?;\n\n    let mut auth_variants = Vec::new();\n    for (_, summary) in summaries {\n        let config = match plugin_manager\n            .get_http_authentication_config(\n                &plugin_context,\n                &summary.name,\n                HashMap::<String, JsonPrimitive>::new(),\n                \"yaakcli_request_schema\",\n            )\n            .await\n        {\n            Ok(config) => config,\n            Err(error) => {\n                eprintln!(\n                    \"Warning: Failed to load auth config for strategy '{}': {}\",\n                    summary.name, error\n                );\n                continue;\n            }\n        };\n\n        auth_variants.push(auth_variant_schema(&summary.name, &summary.label, &config.args));\n    }\n\n    let Some(properties) = schema.get_mut(\"properties\").and_then(Value::as_object_mut) else {\n        return Ok(());\n    };\n\n    let Some(auth_schema) = properties.get_mut(\"authentication\") else {\n        return Ok(());\n    };\n\n    if !auth_variants.is_empty() {\n        let mut one_of = vec![auth_schema.clone()];\n        one_of.extend(auth_variants);\n        *auth_schema = json!({ \"oneOf\": one_of });\n    }\n\n    Ok(())\n}\n\nfn auth_variant_schema(auth_name: &str, auth_label: &str, args: &[FormInput]) -> Value {\n    let mut properties = Map::new();\n    let mut required = Vec::new();\n    for input in args {\n        add_input_schema(input, &mut properties, &mut required);\n    }\n\n    let mut schema = json!({\n        \"title\": auth_label,\n        \"description\": format!(\"Authentication values for strategy '{}'\", auth_name),\n        \"type\": \"object\",\n        \"properties\": properties,\n        \"additionalProperties\": true\n    });\n\n    if !required.is_empty() {\n        schema[\"required\"] = json!(required);\n    }\n\n    schema\n}\n\nfn add_input_schema(\n    input: &FormInput,\n    properties: &mut Map<String, Value>,\n    required: &mut Vec<String>,\n) {\n    match input {\n        FormInput::Text(v) => add_base_schema(\n            &v.base,\n            json!({\n                \"type\": \"string\",\n                \"writeOnly\": v.password.unwrap_or(false),\n            }),\n            properties,\n            required,\n        ),\n        FormInput::Editor(v) => add_base_schema(\n            &v.base,\n            json!({\n                \"type\": \"string\",\n                \"x-editorLanguage\": v.language.clone(),\n            }),\n            properties,\n            required,\n        ),\n        FormInput::Select(v) => {\n            let options: Vec<Value> =\n                v.options.iter().map(|o| Value::String(o.value.clone())).collect();\n            add_base_schema(\n                &v.base,\n                json!({\n                    \"type\": \"string\",\n                    \"enum\": options,\n                }),\n                properties,\n                required,\n            );\n        }\n        FormInput::Checkbox(v) => {\n            add_base_schema(&v.base, json!({ \"type\": \"boolean\" }), properties, required);\n        }\n        FormInput::File(v) => {\n            if v.multiple.unwrap_or(false) {\n                add_base_schema(\n                    &v.base,\n                    json!({\n                        \"type\": \"array\",\n                        \"items\": { \"type\": \"string\" },\n                    }),\n                    properties,\n                    required,\n                );\n            } else {\n                add_base_schema(&v.base, json!({ \"type\": \"string\" }), properties, required);\n            }\n        }\n        FormInput::HttpRequest(v) => {\n            add_base_schema(&v.base, json!({ \"type\": \"string\" }), properties, required);\n        }\n        FormInput::KeyValue(v) => {\n            add_base_schema(\n                &v.base,\n                json!({\n                    \"type\": \"object\",\n                    \"additionalProperties\": true,\n                }),\n                properties,\n                required,\n            );\n        }\n        FormInput::Accordion(v) => {\n            if let Some(children) = &v.inputs {\n                for child in children {\n                    add_input_schema(child, properties, required);\n                }\n            }\n        }\n        FormInput::HStack(v) => {\n            if let Some(children) = &v.inputs {\n                for child in children {\n                    add_input_schema(child, properties, required);\n                }\n            }\n        }\n        FormInput::Banner(v) => {\n            if let Some(children) = &v.inputs {\n                for child in children {\n                    add_input_schema(child, properties, required);\n                }\n            }\n        }\n        FormInput::Markdown(_) => {}\n    }\n}\n\nfn add_base_schema(\n    base: &FormInputBase,\n    mut schema: Value,\n    properties: &mut Map<String, Value>,\n    required: &mut Vec<String>,\n) {\n    if base.hidden.unwrap_or(false) || base.name.trim().is_empty() {\n        return;\n    }\n\n    if let Some(description) = &base.description {\n        schema[\"description\"] = Value::String(description.clone());\n    }\n    if let Some(label) = &base.label {\n        schema[\"title\"] = Value::String(label.clone());\n    }\n    if let Some(default_value) = &base.default_value {\n        schema[\"default\"] = Value::String(default_value.clone());\n    }\n\n    let name = base.name.clone();\n    properties.insert(name.clone(), schema);\n    if !base.optional.unwrap_or(false) {\n        required.push(name);\n    }\n}\n\nfn create(\n    ctx: &CliContext,\n    workspace_id: Option<String>,\n    name: Option<String>,\n    method: Option<String>,\n    url: Option<String>,\n    json: Option<String>,\n) -> CommandResult {\n    let json_shorthand =\n        workspace_id.as_deref().filter(|v| is_json_shorthand(v)).map(str::to_owned);\n    let workspace_id_arg = workspace_id.filter(|v| !is_json_shorthand(v));\n\n    let payload = parse_optional_json(json, json_shorthand, \"request create\")?;\n\n    if let Some(payload) = payload {\n        if name.is_some() || method.is_some() || url.is_some() {\n            return Err(\"request create cannot combine simple flags with JSON payload\".to_string());\n        }\n\n        validate_create_id(&payload, \"request\")?;\n        let mut request: HttpRequest = serde_json::from_value(payload)\n            .map_err(|e| format!(\"Failed to parse request create JSON: {e}\"))?;\n        let fallback_workspace_id = if workspace_id_arg.is_none() && request.workspace_id.is_empty()\n        {\n            Some(resolve_workspace_id(ctx, None, \"request create\")?)\n        } else {\n            None\n        };\n        merge_workspace_id_arg(\n            workspace_id_arg.as_deref().or(fallback_workspace_id.as_deref()),\n            &mut request.workspace_id,\n            \"request create\",\n        )?;\n\n        let created = ctx\n            .db()\n            .upsert_http_request(&request, &UpdateSource::Sync)\n            .map_err(|e| format!(\"Failed to create request: {e}\"))?;\n\n        println!(\"Created request: {}\", created.id);\n        return Ok(());\n    }\n\n    let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), \"request create\")?;\n    let name = name.unwrap_or_default();\n    let url = url.unwrap_or_default();\n    let method = method.unwrap_or_else(|| \"GET\".to_string());\n\n    let request = HttpRequest {\n        workspace_id,\n        name,\n        method: method.to_uppercase(),\n        url,\n        ..Default::default()\n    };\n\n    let created = ctx\n        .db()\n        .upsert_http_request(&request, &UpdateSource::Sync)\n        .map_err(|e| format!(\"Failed to create request: {e}\"))?;\n\n    println!(\"Created request: {}\", created.id);\n    Ok(())\n}\n\nfn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {\n    let patch = parse_required_json(json, json_input, \"request update\")?;\n    let id = require_id(&patch, \"request update\")?;\n\n    let existing = ctx\n        .db()\n        .get_http_request(&id)\n        .map_err(|e| format!(\"Failed to get request for update: {e}\"))?;\n    let updated = apply_merge_patch(&existing, &patch, &id, \"request update\")?;\n\n    let saved = ctx\n        .db()\n        .upsert_http_request(&updated, &UpdateSource::Sync)\n        .map_err(|e| format!(\"Failed to update request: {e}\"))?;\n\n    println!(\"Updated request: {}\", saved.id);\n    Ok(())\n}\n\nfn show(ctx: &CliContext, request_id: &str) -> CommandResult {\n    let request =\n        ctx.db().get_http_request(request_id).map_err(|e| format!(\"Failed to get request: {e}\"))?;\n    let output = serde_json::to_string_pretty(&request)\n        .map_err(|e| format!(\"Failed to serialize request: {e}\"))?;\n    println!(\"{output}\");\n    Ok(())\n}\n\nfn delete(ctx: &CliContext, request_id: &str, yes: bool) -> CommandResult {\n    if !yes && !confirm_delete(\"request\", request_id) {\n        println!(\"Aborted\");\n        return Ok(());\n    }\n\n    let deleted = ctx\n        .db()\n        .delete_http_request_by_id(request_id, &UpdateSource::Sync)\n        .map_err(|e| format!(\"Failed to delete request: {e}\"))?;\n    println!(\"Deleted request: {}\", deleted.id);\n    Ok(())\n}\n\n/// Send a request by ID and print response in the same format as legacy `send`.\npub async fn send_request_by_id(\n    ctx: &CliContext,\n    request_id: &str,\n    environment: Option<&str>,\n    cookie_jar_id: Option<&str>,\n    verbose: bool,\n) -> Result<(), String> {\n    let request =\n        ctx.db().get_any_request(request_id).map_err(|e| format!(\"Failed to get request: {e}\"))?;\n    match request {\n        AnyRequest::HttpRequest(http_request) => {\n            send_http_request_by_id(\n                ctx,\n                &http_request.id,\n                &http_request.workspace_id,\n                environment,\n                cookie_jar_id,\n                verbose,\n            )\n            .await\n        }\n        AnyRequest::GrpcRequest(_) => {\n            Err(\"gRPC request send is not implemented yet in yaak-cli\".to_string())\n        }\n        AnyRequest::WebsocketRequest(_) => {\n            Err(\"WebSocket request send is not implemented yet in yaak-cli\".to_string())\n        }\n    }\n}\n\nasync fn send_http_request_by_id(\n    ctx: &CliContext,\n    request_id: &str,\n    workspace_id: &str,\n    environment: Option<&str>,\n    cookie_jar_id: Option<&str>,\n    verbose: bool,\n) -> Result<(), String> {\n    let cookie_jar_id = resolve_cookie_jar_id(ctx, workspace_id, cookie_jar_id)?;\n\n    let plugin_context =\n        PluginContext::new(Some(\"cli\".to_string()), Some(workspace_id.to_string()));\n\n    let (event_tx, mut event_rx) = mpsc::channel::<SenderHttpResponseEvent>(100);\n    let (body_chunk_tx, mut body_chunk_rx) = mpsc::unbounded_channel::<Vec<u8>>();\n    let event_handle = tokio::spawn(async move {\n        while let Some(event) = event_rx.recv().await {\n            if verbose && !matches!(event, SenderHttpResponseEvent::ChunkReceived { .. }) {\n                println!(\"{}\", event);\n            }\n        }\n    });\n    let body_handle = tokio::task::spawn_blocking(move || {\n        let mut stdout = std::io::stdout();\n        while let Some(chunk) = body_chunk_rx.blocking_recv() {\n            if stdout.write_all(&chunk).is_err() {\n                break;\n            }\n            let _ = stdout.flush();\n        }\n    });\n    let response_dir = ctx.data_dir().join(\"responses\");\n\n    let result = send_http_request_by_id_with_plugins(SendHttpRequestByIdWithPluginsParams {\n        query_manager: ctx.query_manager(),\n        blob_manager: ctx.blob_manager(),\n        request_id,\n        environment_id: environment,\n        update_source: UpdateSource::Sync,\n        cookie_jar_id,\n        response_dir: &response_dir,\n        emit_events_to: Some(event_tx),\n        emit_response_body_chunks_to: Some(body_chunk_tx),\n        plugin_manager: ctx.plugin_manager(),\n        encryption_manager: ctx.encryption_manager.clone(),\n        plugin_context: &plugin_context,\n        cancelled_rx: None,\n        connection_manager: None,\n    })\n    .await;\n\n    let _ = event_handle.await;\n    let _ = body_handle.await;\n    result.map_err(|e| e.to_string())?;\n    Ok(())\n}\n\npub(crate) fn resolve_cookie_jar_id(\n    ctx: &CliContext,\n    workspace_id: &str,\n    explicit_cookie_jar_id: Option<&str>,\n) -> Result<Option<String>, String> {\n    if let Some(cookie_jar_id) = explicit_cookie_jar_id {\n        return Ok(Some(cookie_jar_id.to_string()));\n    }\n\n    let default_cookie_jar = ctx\n        .db()\n        .list_cookie_jars(workspace_id)\n        .map_err(|e| format!(\"Failed to list cookie jars: {e}\"))?\n        .into_iter()\n        .min_by_key(|jar| jar.created_at)\n        .map(|jar| jar.id);\n    Ok(default_cookie_jar)\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/commands/send.rs",
    "content": "use crate::cli::SendArgs;\nuse crate::commands::request;\nuse crate::context::CliContext;\nuse futures::future::join_all;\nuse yaak_models::queries::any_request::AnyRequest;\n\nenum ExecutionMode {\n    Sequential,\n    Parallel,\n}\n\npub async fn run(\n    ctx: &CliContext,\n    args: SendArgs,\n    environment: Option<&str>,\n    cookie_jar_id: Option<&str>,\n    verbose: bool,\n) -> i32 {\n    match send_target(ctx, args, environment, cookie_jar_id, verbose).await {\n        Ok(()) => 0,\n        Err(error) => {\n            eprintln!(\"Error: {error}\");\n            1\n        }\n    }\n}\n\nasync fn send_target(\n    ctx: &CliContext,\n    args: SendArgs,\n    environment: Option<&str>,\n    cookie_jar_id: Option<&str>,\n    verbose: bool,\n) -> Result<(), String> {\n    let mode = if args.parallel { ExecutionMode::Parallel } else { ExecutionMode::Sequential };\n\n    if let Ok(request) = ctx.db().get_any_request(&args.id) {\n        let workspace_id = match &request {\n            AnyRequest::HttpRequest(r) => r.workspace_id.clone(),\n            AnyRequest::GrpcRequest(r) => r.workspace_id.clone(),\n            AnyRequest::WebsocketRequest(r) => r.workspace_id.clone(),\n        };\n        let resolved_cookie_jar_id =\n            request::resolve_cookie_jar_id(ctx, &workspace_id, cookie_jar_id)?;\n\n        return request::send_request_by_id(\n            ctx,\n            &args.id,\n            environment,\n            resolved_cookie_jar_id.as_deref(),\n            verbose,\n        )\n        .await;\n    }\n\n    if let Ok(folder) = ctx.db().get_folder(&args.id) {\n        let resolved_cookie_jar_id =\n            request::resolve_cookie_jar_id(ctx, &folder.workspace_id, cookie_jar_id)?;\n\n        let request_ids = collect_folder_request_ids(ctx, &args.id)?;\n        if request_ids.is_empty() {\n            println!(\"No requests found in folder {}\", args.id);\n            return Ok(());\n        }\n        return send_many(\n            ctx,\n            request_ids,\n            mode,\n            args.fail_fast,\n            environment,\n            resolved_cookie_jar_id.as_deref(),\n            verbose,\n        )\n        .await;\n    }\n\n    if let Ok(workspace) = ctx.db().get_workspace(&args.id) {\n        let resolved_cookie_jar_id =\n            request::resolve_cookie_jar_id(ctx, &workspace.id, cookie_jar_id)?;\n\n        let request_ids = collect_workspace_request_ids(ctx, &args.id)?;\n        if request_ids.is_empty() {\n            println!(\"No requests found in workspace {}\", args.id);\n            return Ok(());\n        }\n        return send_many(\n            ctx,\n            request_ids,\n            mode,\n            args.fail_fast,\n            environment,\n            resolved_cookie_jar_id.as_deref(),\n            verbose,\n        )\n        .await;\n    }\n\n    Err(format!(\"Could not resolve ID '{}' as request, folder, or workspace\", args.id))\n}\n\nfn collect_folder_request_ids(ctx: &CliContext, folder_id: &str) -> Result<Vec<String>, String> {\n    let mut ids = Vec::new();\n\n    let mut http_ids = ctx\n        .db()\n        .list_http_requests_for_folder_recursive(folder_id)\n        .map_err(|e| format!(\"Failed to list HTTP requests in folder: {e}\"))?\n        .into_iter()\n        .map(|r| r.id)\n        .collect::<Vec<_>>();\n    ids.append(&mut http_ids);\n\n    let mut grpc_ids = ctx\n        .db()\n        .list_grpc_requests_for_folder_recursive(folder_id)\n        .map_err(|e| format!(\"Failed to list gRPC requests in folder: {e}\"))?\n        .into_iter()\n        .map(|r| r.id)\n        .collect::<Vec<_>>();\n    ids.append(&mut grpc_ids);\n\n    let mut websocket_ids = ctx\n        .db()\n        .list_websocket_requests_for_folder_recursive(folder_id)\n        .map_err(|e| format!(\"Failed to list WebSocket requests in folder: {e}\"))?\n        .into_iter()\n        .map(|r| r.id)\n        .collect::<Vec<_>>();\n    ids.append(&mut websocket_ids);\n\n    Ok(ids)\n}\n\nfn collect_workspace_request_ids(\n    ctx: &CliContext,\n    workspace_id: &str,\n) -> Result<Vec<String>, String> {\n    let mut ids = Vec::new();\n\n    let mut http_ids = ctx\n        .db()\n        .list_http_requests(workspace_id)\n        .map_err(|e| format!(\"Failed to list HTTP requests in workspace: {e}\"))?\n        .into_iter()\n        .map(|r| r.id)\n        .collect::<Vec<_>>();\n    ids.append(&mut http_ids);\n\n    let mut grpc_ids = ctx\n        .db()\n        .list_grpc_requests(workspace_id)\n        .map_err(|e| format!(\"Failed to list gRPC requests in workspace: {e}\"))?\n        .into_iter()\n        .map(|r| r.id)\n        .collect::<Vec<_>>();\n    ids.append(&mut grpc_ids);\n\n    let mut websocket_ids = ctx\n        .db()\n        .list_websocket_requests(workspace_id)\n        .map_err(|e| format!(\"Failed to list WebSocket requests in workspace: {e}\"))?\n        .into_iter()\n        .map(|r| r.id)\n        .collect::<Vec<_>>();\n    ids.append(&mut websocket_ids);\n\n    Ok(ids)\n}\n\nasync fn send_many(\n    ctx: &CliContext,\n    request_ids: Vec<String>,\n    mode: ExecutionMode,\n    fail_fast: bool,\n    environment: Option<&str>,\n    cookie_jar_id: Option<&str>,\n    verbose: bool,\n) -> Result<(), String> {\n    let mut success_count = 0usize;\n    let mut failures: Vec<(String, String)> = Vec::new();\n\n    match mode {\n        ExecutionMode::Sequential => {\n            for request_id in request_ids {\n                match request::send_request_by_id(\n                    ctx,\n                    &request_id,\n                    environment,\n                    cookie_jar_id,\n                    verbose,\n                )\n                .await\n                {\n                    Ok(()) => success_count += 1,\n                    Err(error) => {\n                        failures.push((request_id, error));\n                        if fail_fast {\n                            break;\n                        }\n                    }\n                }\n            }\n        }\n        ExecutionMode::Parallel => {\n            let tasks = request_ids\n                .iter()\n                .map(|request_id| async move {\n                    (\n                        request_id.clone(),\n                        request::send_request_by_id(\n                            ctx,\n                            request_id,\n                            environment,\n                            cookie_jar_id,\n                            verbose,\n                        )\n                        .await,\n                    )\n                })\n                .collect::<Vec<_>>();\n\n            for (request_id, result) in join_all(tasks).await {\n                match result {\n                    Ok(()) => success_count += 1,\n                    Err(error) => failures.push((request_id, error)),\n                }\n            }\n        }\n    }\n\n    let failure_count = failures.len();\n    println!(\"Send summary: {success_count} succeeded, {failure_count} failed\");\n\n    if failure_count == 0 {\n        return Ok(());\n    }\n\n    for (request_id, error) in failures {\n        eprintln!(\"  {}: {}\", request_id, error);\n    }\n    Err(\"One or more requests failed\".to_string())\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/commands/workspace.rs",
    "content": "use crate::cli::{WorkspaceArgs, WorkspaceCommands};\nuse crate::context::CliContext;\nuse crate::utils::confirm::confirm_delete;\nuse crate::utils::json::{\n    apply_merge_patch, parse_optional_json, parse_required_json, require_id, validate_create_id,\n};\nuse crate::utils::schema::append_agent_hints;\nuse schemars::schema_for;\nuse yaak_models::models::Workspace;\nuse yaak_models::util::UpdateSource;\n\ntype CommandResult<T = ()> = std::result::Result<T, String>;\n\npub fn run(ctx: &CliContext, args: WorkspaceArgs) -> i32 {\n    let result = match args.command {\n        WorkspaceCommands::List => list(ctx),\n        WorkspaceCommands::Schema { pretty } => schema(pretty),\n        WorkspaceCommands::Show { workspace_id } => show(ctx, &workspace_id),\n        WorkspaceCommands::Create { name, json, json_input } => create(ctx, name, json, json_input),\n        WorkspaceCommands::Update { json, json_input } => update(ctx, json, json_input),\n        WorkspaceCommands::Delete { workspace_id, yes } => delete(ctx, &workspace_id, yes),\n    };\n\n    match result {\n        Ok(()) => 0,\n        Err(error) => {\n            eprintln!(\"Error: {error}\");\n            1\n        }\n    }\n}\n\nfn schema(pretty: bool) -> CommandResult {\n    let mut schema = serde_json::to_value(schema_for!(Workspace))\n        .map_err(|e| format!(\"Failed to serialize workspace schema: {e}\"))?;\n    append_agent_hints(&mut schema);\n\n    let output =\n        if pretty { serde_json::to_string_pretty(&schema) } else { serde_json::to_string(&schema) }\n            .map_err(|e| format!(\"Failed to format workspace schema JSON: {e}\"))?;\n    println!(\"{output}\");\n    Ok(())\n}\n\nfn list(ctx: &CliContext) -> CommandResult {\n    let workspaces =\n        ctx.db().list_workspaces().map_err(|e| format!(\"Failed to list workspaces: {e}\"))?;\n    if workspaces.is_empty() {\n        println!(\"No workspaces found\");\n    } else {\n        for workspace in workspaces {\n            println!(\"{} - {}\", workspace.id, workspace.name);\n        }\n    }\n    Ok(())\n}\n\nfn show(ctx: &CliContext, workspace_id: &str) -> CommandResult {\n    let workspace = ctx\n        .db()\n        .get_workspace(workspace_id)\n        .map_err(|e| format!(\"Failed to get workspace: {e}\"))?;\n    let output = serde_json::to_string_pretty(&workspace)\n        .map_err(|e| format!(\"Failed to serialize workspace: {e}\"))?;\n    println!(\"{output}\");\n    Ok(())\n}\n\nfn create(\n    ctx: &CliContext,\n    name: Option<String>,\n    json: Option<String>,\n    json_input: Option<String>,\n) -> CommandResult {\n    let payload = parse_optional_json(json, json_input, \"workspace create\")?;\n\n    if let Some(payload) = payload {\n        if name.is_some() {\n            return Err(\"workspace create cannot combine --name with JSON payload\".to_string());\n        }\n\n        validate_create_id(&payload, \"workspace\")?;\n        let workspace: Workspace = serde_json::from_value(payload)\n            .map_err(|e| format!(\"Failed to parse workspace create JSON: {e}\"))?;\n\n        let created = ctx\n            .db()\n            .upsert_workspace(&workspace, &UpdateSource::Sync)\n            .map_err(|e| format!(\"Failed to create workspace: {e}\"))?;\n        println!(\"Created workspace: {}\", created.id);\n        return Ok(());\n    }\n\n    let name = name.ok_or_else(|| {\n        \"workspace create requires --name unless JSON payload is provided\".to_string()\n    })?;\n\n    let workspace = Workspace { name, ..Default::default() };\n    let created = ctx\n        .db()\n        .upsert_workspace(&workspace, &UpdateSource::Sync)\n        .map_err(|e| format!(\"Failed to create workspace: {e}\"))?;\n    println!(\"Created workspace: {}\", created.id);\n    Ok(())\n}\n\nfn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {\n    let patch = parse_required_json(json, json_input, \"workspace update\")?;\n    let id = require_id(&patch, \"workspace update\")?;\n\n    let existing = ctx\n        .db()\n        .get_workspace(&id)\n        .map_err(|e| format!(\"Failed to get workspace for update: {e}\"))?;\n    let updated = apply_merge_patch(&existing, &patch, &id, \"workspace update\")?;\n\n    let saved = ctx\n        .db()\n        .upsert_workspace(&updated, &UpdateSource::Sync)\n        .map_err(|e| format!(\"Failed to update workspace: {e}\"))?;\n\n    println!(\"Updated workspace: {}\", saved.id);\n    Ok(())\n}\n\nfn delete(ctx: &CliContext, workspace_id: &str, yes: bool) -> CommandResult {\n    if !yes && !confirm_delete(\"workspace\", workspace_id) {\n        println!(\"Aborted\");\n        return Ok(());\n    }\n\n    let deleted = ctx\n        .db()\n        .delete_workspace_by_id(workspace_id, &UpdateSource::Sync)\n        .map_err(|e| format!(\"Failed to delete workspace: {e}\"))?;\n    println!(\"Deleted workspace: {}\", deleted.id);\n    Ok(())\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/context.rs",
    "content": "use crate::plugin_events::CliPluginEventBridge;\nuse include_dir::{Dir, include_dir};\nuse std::fs;\nuse std::path::{Path, PathBuf};\nuse std::sync::Arc;\nuse tokio::sync::Mutex;\nuse yaak_crypto::manager::EncryptionManager;\nuse yaak_models::blob_manager::BlobManager;\nuse yaak_models::db_context::DbContext;\nuse yaak_models::query_manager::QueryManager;\nuse yaak_plugins::events::PluginContext;\nuse yaak_plugins::manager::PluginManager;\n\nconst EMBEDDED_PLUGIN_RUNTIME: &str = include_str!(concat!(\n    env!(\"CARGO_MANIFEST_DIR\"),\n    \"/../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs\"\n));\nstatic EMBEDDED_VENDORED_PLUGINS: Dir<'_> =\n    include_dir!(\"$CARGO_MANIFEST_DIR/../../crates-tauri/yaak-app/vendored/plugins\");\n\n#[derive(Clone, Debug, Default)]\npub struct CliExecutionContext {\n    pub request_id: Option<String>,\n    pub workspace_id: Option<String>,\n    pub environment_id: Option<String>,\n    pub cookie_jar_id: Option<String>,\n}\n\npub struct CliContext {\n    data_dir: PathBuf,\n    query_manager: QueryManager,\n    blob_manager: BlobManager,\n    pub encryption_manager: Arc<EncryptionManager>,\n    plugin_manager: Option<Arc<PluginManager>>,\n    plugin_event_bridge: Mutex<Option<CliPluginEventBridge>>,\n}\n\nimpl CliContext {\n    pub fn new(data_dir: PathBuf, app_id: &str) -> Self {\n        let db_path = data_dir.join(\"db.sqlite\");\n        let blob_path = data_dir.join(\"blobs.sqlite\");\n        let (query_manager, blob_manager, _rx) =\n            match yaak_models::init_standalone(&db_path, &blob_path) {\n                Ok(v) => v,\n                Err(err) => {\n                    eprintln!(\"Error: Failed to initialize database: {err}\");\n                    std::process::exit(1);\n                }\n            };\n        let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));\n\n        Self {\n            data_dir,\n            query_manager,\n            blob_manager,\n            encryption_manager,\n            plugin_manager: None,\n            plugin_event_bridge: Mutex::new(None),\n        }\n    }\n\n    pub async fn init_plugins(&mut self, execution_context: CliExecutionContext) {\n        let vendored_plugin_dir = self.data_dir.join(\"vendored-plugins\");\n        let installed_plugin_dir = self.data_dir.join(\"installed-plugins\");\n        let node_bin_path = PathBuf::from(\"node\");\n\n        prepare_embedded_vendored_plugins(&vendored_plugin_dir)\n            .expect(\"Failed to prepare bundled plugins\");\n\n        let plugin_runtime_main =\n            std::env::var(\"YAAK_PLUGIN_RUNTIME\").map(PathBuf::from).unwrap_or_else(|_| {\n                prepare_embedded_plugin_runtime(&self.data_dir)\n                    .expect(\"Failed to prepare embedded plugin runtime\")\n            });\n\n        match PluginManager::new(\n            vendored_plugin_dir,\n            installed_plugin_dir,\n            node_bin_path,\n            plugin_runtime_main,\n            &self.query_manager,\n            &PluginContext::new_empty(),\n            false,\n        )\n        .await\n        {\n            Ok(plugin_manager) => {\n                let plugin_manager = Arc::new(plugin_manager);\n                let plugin_event_bridge = CliPluginEventBridge::start(\n                    plugin_manager.clone(),\n                    self.query_manager.clone(),\n                    self.blob_manager.clone(),\n                    self.encryption_manager.clone(),\n                    self.data_dir.clone(),\n                    execution_context,\n                )\n                .await;\n                self.plugin_manager = Some(plugin_manager);\n                *self.plugin_event_bridge.lock().await = Some(plugin_event_bridge);\n            }\n            Err(err) => {\n                eprintln!(\"Warning: Failed to initialize plugins: {err}\");\n            }\n        }\n    }\n\n    pub fn data_dir(&self) -> &Path {\n        &self.data_dir\n    }\n\n    pub fn db(&self) -> DbContext<'_> {\n        self.query_manager.connect()\n    }\n\n    pub fn query_manager(&self) -> &QueryManager {\n        &self.query_manager\n    }\n\n    pub fn blob_manager(&self) -> &BlobManager {\n        &self.blob_manager\n    }\n\n    pub fn plugin_manager(&self) -> Arc<PluginManager> {\n        self.plugin_manager.clone().expect(\"Plugin manager was not initialized for this command\")\n    }\n\n    pub async fn shutdown(&self) {\n        if let Some(plugin_manager) = &self.plugin_manager {\n            if let Some(plugin_event_bridge) = self.plugin_event_bridge.lock().await.take() {\n                plugin_event_bridge.shutdown(plugin_manager).await;\n            }\n            plugin_manager.terminate().await;\n        }\n    }\n}\n\nfn prepare_embedded_plugin_runtime(data_dir: &Path) -> std::io::Result<PathBuf> {\n    let runtime_dir = data_dir.join(\"vendored\").join(\"plugin-runtime\");\n    fs::create_dir_all(&runtime_dir)?;\n    let runtime_main = runtime_dir.join(\"index.cjs\");\n    fs::write(&runtime_main, EMBEDDED_PLUGIN_RUNTIME)?;\n    Ok(runtime_main)\n}\n\nfn prepare_embedded_vendored_plugins(vendored_plugin_dir: &Path) -> std::io::Result<()> {\n    fs::create_dir_all(vendored_plugin_dir)?;\n    EMBEDDED_VENDORED_PLUGINS.extract(vendored_plugin_dir)?;\n    Ok(())\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/main.rs",
    "content": "mod cli;\nmod commands;\nmod context;\nmod plugin_events;\nmod ui;\nmod utils;\nmod version;\nmod version_check;\n\nuse clap::Parser;\nuse cli::{Cli, Commands, PluginCommands, RequestCommands};\nuse context::{CliContext, CliExecutionContext};\nuse std::path::PathBuf;\nuse yaak_models::queries::any_request::AnyRequest;\n\n#[tokio::main]\nasync fn main() {\n    let Cli { data_dir, environment, cookie_jar, verbose, log, command } = Cli::parse();\n\n    if let Some(log_level) = log {\n        match log_level {\n            Some(level) => {\n                env_logger::Builder::new().filter_level(level.as_filter()).init();\n            }\n            None => {\n                env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(\"info\"))\n                    .init();\n            }\n        }\n    }\n\n    let app_id = if cfg!(debug_assertions) { \"app.yaak.desktop.dev\" } else { \"app.yaak.desktop\" };\n\n    let data_dir = data_dir.unwrap_or_else(|| resolve_data_dir(app_id));\n\n    version_check::maybe_check_for_updates().await;\n\n    let exit_code = match command {\n        Commands::Auth(args) => commands::auth::run(args).await,\n        Commands::Plugin(args) => match args.command {\n            PluginCommands::Build(args) => commands::plugin::run_build(args).await,\n            PluginCommands::Dev(args) => commands::plugin::run_dev(args).await,\n            PluginCommands::Generate(args) => commands::plugin::run_generate(args).await,\n            PluginCommands::Publish(args) => commands::plugin::run_publish(args).await,\n            PluginCommands::Install(install_args) => {\n                let mut context = CliContext::new(data_dir.clone(), app_id);\n                context.init_plugins(CliExecutionContext::default()).await;\n                let exit_code = commands::plugin::run_install(&context, install_args).await;\n                context.shutdown().await;\n                exit_code\n            }\n        },\n        Commands::Build(args) => commands::plugin::run_build(args).await,\n        Commands::Dev(args) => commands::plugin::run_dev(args).await,\n        Commands::Generate(args) => commands::plugin::run_generate(args).await,\n        Commands::Publish(args) => commands::plugin::run_publish(args).await,\n        Commands::Send(args) => {\n            let mut context = CliContext::new(data_dir.clone(), app_id);\n            match resolve_send_execution_context(\n                &context,\n                &args.id,\n                environment.as_deref(),\n                cookie_jar.as_deref(),\n            ) {\n                Ok(execution_context) => {\n                    context.init_plugins(execution_context).await;\n                    let exit_code = commands::send::run(\n                        &context,\n                        args,\n                        environment.as_deref(),\n                        cookie_jar.as_deref(),\n                        verbose,\n                    )\n                    .await;\n                    context.shutdown().await;\n                    exit_code\n                }\n                Err(error) => {\n                    eprintln!(\"Error: {error}\");\n                    1\n                }\n            }\n        }\n        Commands::CookieJar(args) => {\n            let context = CliContext::new(data_dir.clone(), app_id);\n            let exit_code = commands::cookie_jar::run(&context, args);\n            context.shutdown().await;\n            exit_code\n        }\n        Commands::Workspace(args) => {\n            let context = CliContext::new(data_dir.clone(), app_id);\n            let exit_code = commands::workspace::run(&context, args);\n            context.shutdown().await;\n            exit_code\n        }\n        Commands::Request(args) => {\n            let mut context = CliContext::new(data_dir.clone(), app_id);\n            let execution_context_result = match &args.command {\n                RequestCommands::Send { request_id } => resolve_request_execution_context(\n                    &context,\n                    request_id,\n                    environment.as_deref(),\n                    cookie_jar.as_deref(),\n                ),\n                _ => Ok(CliExecutionContext::default()),\n            };\n            match execution_context_result {\n                Ok(execution_context) => {\n                    let with_plugins = matches!(\n                        &args.command,\n                        RequestCommands::Send { .. } | RequestCommands::Schema { .. }\n                    );\n                    if with_plugins {\n                        context.init_plugins(execution_context).await;\n                    }\n                    let exit_code = commands::request::run(\n                        &context,\n                        args,\n                        environment.as_deref(),\n                        cookie_jar.as_deref(),\n                        verbose,\n                    )\n                    .await;\n                    context.shutdown().await;\n                    exit_code\n                }\n                Err(error) => {\n                    eprintln!(\"Error: {error}\");\n                    1\n                }\n            }\n        }\n        Commands::Folder(args) => {\n            let context = CliContext::new(data_dir.clone(), app_id);\n            let exit_code = commands::folder::run(&context, args);\n            context.shutdown().await;\n            exit_code\n        }\n        Commands::Environment(args) => {\n            let context = CliContext::new(data_dir.clone(), app_id);\n            let exit_code = commands::environment::run(&context, args);\n            context.shutdown().await;\n            exit_code\n        }\n    };\n\n    if exit_code != 0 {\n        std::process::exit(exit_code);\n    }\n}\n\nfn resolve_send_execution_context(\n    context: &CliContext,\n    id: &str,\n    environment: Option<&str>,\n    explicit_cookie_jar_id: Option<&str>,\n) -> Result<CliExecutionContext, String> {\n    if let Ok(request) = context.db().get_any_request(id) {\n        let (request_id, workspace_id) = match request {\n            AnyRequest::HttpRequest(r) => (Some(r.id), r.workspace_id),\n            AnyRequest::GrpcRequest(r) => (Some(r.id), r.workspace_id),\n            AnyRequest::WebsocketRequest(r) => (Some(r.id), r.workspace_id),\n        };\n        let cookie_jar_id = resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?;\n        return Ok(CliExecutionContext {\n            request_id,\n            workspace_id: Some(workspace_id),\n            environment_id: environment.map(str::to_string),\n            cookie_jar_id,\n        });\n    }\n\n    if let Ok(folder) = context.db().get_folder(id) {\n        let cookie_jar_id =\n            resolve_cookie_jar_id(context, &folder.workspace_id, explicit_cookie_jar_id)?;\n        return Ok(CliExecutionContext {\n            request_id: None,\n            workspace_id: Some(folder.workspace_id),\n            environment_id: environment.map(str::to_string),\n            cookie_jar_id,\n        });\n    }\n\n    if let Ok(workspace) = context.db().get_workspace(id) {\n        let cookie_jar_id = resolve_cookie_jar_id(context, &workspace.id, explicit_cookie_jar_id)?;\n        return Ok(CliExecutionContext {\n            request_id: None,\n            workspace_id: Some(workspace.id),\n            environment_id: environment.map(str::to_string),\n            cookie_jar_id,\n        });\n    }\n\n    Err(format!(\"Could not resolve ID '{}' as request, folder, or workspace\", id))\n}\n\nfn resolve_request_execution_context(\n    context: &CliContext,\n    request_id: &str,\n    environment: Option<&str>,\n    explicit_cookie_jar_id: Option<&str>,\n) -> Result<CliExecutionContext, String> {\n    let request = context\n        .db()\n        .get_any_request(request_id)\n        .map_err(|e| format!(\"Failed to get request: {e}\"))?;\n\n    let workspace_id = match request {\n        AnyRequest::HttpRequest(r) => r.workspace_id,\n        AnyRequest::GrpcRequest(r) => r.workspace_id,\n        AnyRequest::WebsocketRequest(r) => r.workspace_id,\n    };\n    let cookie_jar_id = resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?;\n\n    Ok(CliExecutionContext {\n        request_id: Some(request_id.to_string()),\n        workspace_id: Some(workspace_id),\n        environment_id: environment.map(str::to_string),\n        cookie_jar_id,\n    })\n}\n\nfn resolve_cookie_jar_id(\n    context: &CliContext,\n    workspace_id: &str,\n    explicit_cookie_jar_id: Option<&str>,\n) -> Result<Option<String>, String> {\n    if let Some(cookie_jar_id) = explicit_cookie_jar_id {\n        return Ok(Some(cookie_jar_id.to_string()));\n    }\n\n    let default_cookie_jar = context\n        .db()\n        .list_cookie_jars(workspace_id)\n        .map_err(|e| format!(\"Failed to list cookie jars: {e}\"))?\n        .into_iter()\n        .min_by_key(|jar| jar.created_at)\n        .map(|jar| jar.id);\n    Ok(default_cookie_jar)\n}\n\nfn resolve_data_dir(app_id: &str) -> PathBuf {\n    if let Some(dir) = wsl_data_dir(app_id) {\n        return dir;\n    }\n    dirs::data_dir().expect(\"Could not determine data directory\").join(app_id)\n}\n\n/// Detect WSL and resolve the Windows AppData\\Roaming path for the Yaak data directory.\nfn wsl_data_dir(app_id: &str) -> Option<PathBuf> {\n    if !cfg!(target_os = \"linux\") {\n        return None;\n    }\n\n    let proc_version = std::fs::read_to_string(\"/proc/version\").ok()?;\n    let is_wsl = proc_version.to_lowercase().contains(\"microsoft\");\n    if !is_wsl {\n        return None;\n    }\n\n    // We're in WSL, so try to resolve the Yaak app's data directory in Windows\n\n    // Get the Windows %APPDATA% path via cmd.exe\n    let appdata_output =\n        std::process::Command::new(\"cmd.exe\").args([\"/C\", \"echo\", \"%APPDATA%\"]).output().ok()?;\n\n    let win_path = String::from_utf8(appdata_output.stdout).ok()?.trim().to_string();\n    if win_path.is_empty() || win_path == \"%APPDATA%\" {\n        return None;\n    }\n\n    // Convert Windows path to WSL path using wslpath (handles custom mount points)\n    let wslpath_output = std::process::Command::new(\"wslpath\").arg(&win_path).output().ok()?;\n\n    let wsl_appdata = String::from_utf8(wslpath_output.stdout).ok()?.trim().to_string();\n    if wsl_appdata.is_empty() {\n        return None;\n    }\n\n    let wsl_path = PathBuf::from(wsl_appdata).join(app_id);\n\n    if wsl_path.exists() { Some(wsl_path) } else { None }\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/plugin_events.rs",
    "content": "use crate::context::CliExecutionContext;\nuse arboard::Clipboard;\nuse console::Term;\nuse inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text};\nuse serde_json::Value;\nuse std::collections::HashMap;\nuse std::io::IsTerminal;\nuse std::path::PathBuf;\nuse std::sync::Arc;\nuse tokio::task::JoinHandle;\nuse yaak::plugin_events::{\n    GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,\n};\nuse yaak::render::{render_grpc_request, render_http_request};\nuse yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};\nuse yaak_crypto::manager::EncryptionManager;\nuse yaak_models::blob_manager::BlobManager;\nuse yaak_models::models::Environment;\nuse yaak_models::queries::any_request::AnyRequest;\nuse yaak_models::query_manager::QueryManager;\nuse yaak_models::render::make_vars_hashmap;\nuse yaak_models::util::UpdateSource;\nuse yaak_plugins::events::{\n    EmptyPayload, ErrorResponse, FormInput, GetCookieValueResponse, InternalEvent,\n    InternalEventPayload, JsonPrimitive, ListCookieNamesResponse, ListOpenWorkspacesResponse,\n    PluginContext, PromptFormRequest, PromptFormResponse, PromptTextRequest, PromptTextResponse,\n    RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,\n    TemplateRenderResponse, WindowInfoResponse, WorkspaceInfo,\n};\nuse yaak_plugins::manager::PluginManager;\nuse yaak_plugins::template_callback::PluginTemplateCallback;\nuse yaak_templates::{RenderOptions, TemplateCallback, render_json_value_raw};\n\npub struct CliPluginEventBridge {\n    rx_id: String,\n    task: JoinHandle<()>,\n}\n\nstruct CliHostContext {\n    query_manager: QueryManager,\n    blob_manager: BlobManager,\n    plugin_manager: Arc<PluginManager>,\n    encryption_manager: Arc<EncryptionManager>,\n    response_dir: PathBuf,\n    execution_context: CliExecutionContext,\n}\n\nimpl CliPluginEventBridge {\n    pub async fn start(\n        plugin_manager: Arc<PluginManager>,\n        query_manager: QueryManager,\n        blob_manager: BlobManager,\n        encryption_manager: Arc<EncryptionManager>,\n        data_dir: PathBuf,\n        execution_context: CliExecutionContext,\n    ) -> Self {\n        let (rx_id, mut rx) = plugin_manager.subscribe(\"cli\").await;\n        let rx_id_for_task = rx_id.clone();\n        let pm = plugin_manager.clone();\n        let host_context = Arc::new(CliHostContext {\n            query_manager,\n            blob_manager,\n            plugin_manager,\n            encryption_manager,\n            response_dir: data_dir.join(\"responses\"),\n            execution_context,\n        });\n\n        let task = tokio::spawn(async move {\n            while let Some(event) = rx.recv().await {\n                // Events with reply IDs are replies to app-originated requests.\n                if event.reply_id.is_some() {\n                    continue;\n                }\n\n                let Some(plugin_handle) = pm.get_plugin_by_ref_id(&event.plugin_ref_id).await\n                else {\n                    eprintln!(\n                        \"Warning: Ignoring plugin event with unknown plugin ref '{}'\",\n                        event.plugin_ref_id\n                    );\n                    continue;\n                };\n\n                let pm = pm.clone();\n                let host_context = host_context.clone();\n\n                // Avoid deadlocks for nested plugin-host requests (for example, template functions\n                // that trigger additional host requests during render) by handling each event in\n                // its own task.\n                tokio::spawn(async move {\n                    let plugin_name = plugin_handle.info().name;\n                    let Some(reply_payload) =\n                        build_plugin_reply(host_context.as_ref(), &event, &plugin_name).await\n                    else {\n                        return;\n                    };\n\n                    if let Err(err) = pm.reply(&event, &reply_payload).await {\n                        eprintln!(\"Warning: Failed replying to plugin event: {err}\");\n                    }\n                });\n            }\n\n            pm.unsubscribe(&rx_id_for_task).await;\n        });\n\n        Self { rx_id, task }\n    }\n\n    pub async fn shutdown(self, plugin_manager: &PluginManager) {\n        plugin_manager.unsubscribe(&self.rx_id).await;\n        self.task.abort();\n        let _ = self.task.await;\n    }\n}\n\nasync fn build_plugin_reply(\n    host_context: &CliHostContext,\n    event: &InternalEvent,\n    plugin_name: &str,\n) -> Option<InternalEventPayload> {\n    let execution_context = &host_context.execution_context;\n    let shared_workspace_id =\n        event.context.workspace_id.as_deref().or(execution_context.workspace_id.as_deref());\n\n    match handle_shared_plugin_event(\n        &host_context.query_manager,\n        &event.payload,\n        SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },\n    ) {\n        GroupedPluginEvent::Handled(payload) => payload,\n        GroupedPluginEvent::ToHandle(host_request) => match host_request {\n            HostRequest::ErrorResponse(resp) => {\n                eprintln!(\"[plugin:{}] error: {}\", plugin_name, resp.error);\n                None\n            }\n            HostRequest::ReloadResponse(_) => None,\n            HostRequest::ShowToast(req) => {\n                eprintln!(\"[plugin:{}] {}\", plugin_name, req.message);\n                Some(InternalEventPayload::ShowToastResponse(EmptyPayload {}))\n            }\n            HostRequest::ListOpenWorkspaces(_) => {\n                let workspaces = match host_context.query_manager.connect().list_workspaces() {\n                    Ok(workspaces) => workspaces\n                        .into_iter()\n                        .map(|w| WorkspaceInfo { id: w.id.clone(), name: w.name, label: w.id })\n                        .collect(),\n                    Err(err) => {\n                        return Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                            error: format!(\"Failed to list workspaces in CLI: {err}\"),\n                        }));\n                    }\n                };\n                Some(InternalEventPayload::ListOpenWorkspacesResponse(ListOpenWorkspacesResponse {\n                    workspaces,\n                }))\n            }\n            HostRequest::SendHttpRequest(send_http_request_request) => {\n                let mut http_request = send_http_request_request.http_request.clone();\n                if http_request.workspace_id.is_empty() {\n                    let Some(workspace_id) = event\n                        .context\n                        .workspace_id\n                        .clone()\n                        .or_else(|| execution_context.workspace_id.clone())\n                    else {\n                        return Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                            error: \"workspace_id is required to send HTTP requests in CLI\"\n                                .to_string(),\n                        }));\n                    };\n                    http_request.workspace_id = workspace_id;\n                }\n\n                let cookie_jar_id =\n                    if let Some(cookie_jar_id) = execution_context.cookie_jar_id.clone() {\n                        Some(cookie_jar_id)\n                    } else {\n                        match host_context\n                            .query_manager\n                            .connect()\n                            .list_cookie_jars(http_request.workspace_id.as_str())\n                        {\n                            Ok(cookie_jars) => cookie_jars\n                                .into_iter()\n                                .min_by_key(|jar| jar.created_at)\n                                .map(|jar| jar.id),\n                            Err(err) => {\n                                return Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                                    error: format!(\"Failed to list cookie jars in CLI: {err}\"),\n                                }));\n                            }\n                        }\n                    };\n                let plugin_context = PluginContext {\n                    workspace_id: Some(http_request.workspace_id.clone()),\n                    ..event.context.clone()\n                };\n\n                match send_http_request_with_plugins(SendHttpRequestWithPluginsParams {\n                    query_manager: &host_context.query_manager,\n                    blob_manager: &host_context.blob_manager,\n                    request: http_request,\n                    environment_id: execution_context.environment_id.as_deref(),\n                    update_source: UpdateSource::Plugin,\n                    cookie_jar_id,\n                    response_dir: &host_context.response_dir,\n                    emit_events_to: None,\n                    emit_response_body_chunks_to: None,\n                    existing_response: None,\n                    plugin_manager: host_context.plugin_manager.clone(),\n                    encryption_manager: host_context.encryption_manager.clone(),\n                    plugin_context: &plugin_context,\n                    cancelled_rx: None,\n                    connection_manager: None,\n                })\n                .await\n                {\n                    Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(\n                        SendHttpRequestResponse { http_response: result.response },\n                    )),\n                    Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                        error: format!(\"Failed to send HTTP request in CLI: {err}\"),\n                    })),\n                }\n            }\n            HostRequest::RenderGrpcRequest(render_grpc_request_request) => {\n                let mut grpc_request = render_grpc_request_request.grpc_request.clone();\n                if grpc_request.workspace_id.is_empty() {\n                    let Some(workspace_id) = event\n                        .context\n                        .workspace_id\n                        .clone()\n                        .or_else(|| execution_context.workspace_id.clone())\n                    else {\n                        return Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                            error: \"workspace_id is required to render gRPC requests in CLI\"\n                                .to_string(),\n                        }));\n                    };\n                    grpc_request.workspace_id = workspace_id;\n                }\n\n                let plugin_context = PluginContext {\n                    workspace_id: Some(grpc_request.workspace_id.clone()),\n                    ..event.context.clone()\n                };\n\n                let environment_chain =\n                    match host_context.query_manager.connect().resolve_environments(\n                        &grpc_request.workspace_id,\n                        grpc_request.folder_id.as_deref(),\n                        execution_context.environment_id.as_deref(),\n                    ) {\n                        Ok(chain) => chain,\n                        Err(err) => {\n                            return Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to resolve environments in CLI: {err}\"),\n                            }));\n                        }\n                    };\n\n                let template_callback = PluginTemplateCallback::new(\n                    host_context.plugin_manager.clone(),\n                    host_context.encryption_manager.clone(),\n                    &plugin_context,\n                    render_grpc_request_request.purpose.clone(),\n                );\n                let render_options = RenderOptions::throw();\n\n                match render_grpc_request(\n                    &grpc_request,\n                    environment_chain,\n                    &template_callback,\n                    &render_options,\n                )\n                .await\n                {\n                    Ok(grpc_request) => Some(InternalEventPayload::RenderGrpcRequestResponse(\n                        RenderGrpcRequestResponse { grpc_request },\n                    )),\n                    Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                        error: format!(\"Failed to render gRPC request in CLI: {err}\"),\n                    })),\n                }\n            }\n            HostRequest::RenderHttpRequest(render_http_request_request) => {\n                let mut http_request = render_http_request_request.http_request.clone();\n                if http_request.workspace_id.is_empty() {\n                    let Some(workspace_id) = event\n                        .context\n                        .workspace_id\n                        .clone()\n                        .or_else(|| execution_context.workspace_id.clone())\n                    else {\n                        return Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                            error: \"workspace_id is required to render HTTP requests in CLI\"\n                                .to_string(),\n                        }));\n                    };\n                    http_request.workspace_id = workspace_id;\n                }\n\n                let plugin_context = PluginContext {\n                    workspace_id: Some(http_request.workspace_id.clone()),\n                    ..event.context.clone()\n                };\n\n                let environment_chain =\n                    match host_context.query_manager.connect().resolve_environments(\n                        &http_request.workspace_id,\n                        http_request.folder_id.as_deref(),\n                        execution_context.environment_id.as_deref(),\n                    ) {\n                        Ok(chain) => chain,\n                        Err(err) => {\n                            return Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to resolve environments in CLI: {err}\"),\n                            }));\n                        }\n                    };\n\n                let template_callback = PluginTemplateCallback::new(\n                    host_context.plugin_manager.clone(),\n                    host_context.encryption_manager.clone(),\n                    &plugin_context,\n                    render_http_request_request.purpose.clone(),\n                );\n                let render_options = RenderOptions::throw();\n\n                match render_http_request(\n                    &http_request,\n                    environment_chain,\n                    &template_callback,\n                    &render_options,\n                )\n                .await\n                {\n                    Ok(http_request) => Some(InternalEventPayload::RenderHttpRequestResponse(\n                        RenderHttpRequestResponse { http_request },\n                    )),\n                    Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                        error: format!(\"Failed to render HTTP request in CLI: {err}\"),\n                    })),\n                }\n            }\n            HostRequest::TemplateRender(template_render_request) => {\n                let Some(workspace_id) = event\n                    .context\n                    .workspace_id\n                    .clone()\n                    .or_else(|| execution_context.workspace_id.clone())\n                else {\n                    return Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                        error: \"workspace_id is required to render templates in CLI\".to_string(),\n                    }));\n                };\n\n                let plugin_context = PluginContext {\n                    workspace_id: Some(workspace_id.clone()),\n                    ..event.context.clone()\n                };\n\n                let folder_id = execution_context.request_id.as_ref().and_then(|rid| {\n                    match host_context.query_manager.connect().get_any_request(rid) {\n                        Ok(AnyRequest::HttpRequest(r)) => r.folder_id,\n                        Ok(AnyRequest::GrpcRequest(r)) => r.folder_id,\n                        Ok(AnyRequest::WebsocketRequest(r)) => r.folder_id,\n                        Err(_) => None,\n                    }\n                });\n\n                let environment_chain =\n                    match host_context.query_manager.connect().resolve_environments(\n                        &workspace_id,\n                        folder_id.as_deref(),\n                        execution_context.environment_id.as_deref(),\n                    ) {\n                        Ok(chain) => chain,\n                        Err(err) => {\n                            return Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to resolve environments in CLI: {err}\"),\n                            }));\n                        }\n                    };\n\n                let template_callback = PluginTemplateCallback::new(\n                    host_context.plugin_manager.clone(),\n                    host_context.encryption_manager.clone(),\n                    &plugin_context,\n                    template_render_request.purpose.clone(),\n                );\n                let render_options = RenderOptions::throw();\n\n                match render_json_value_for_cli(\n                    template_render_request.data.clone(),\n                    environment_chain,\n                    &template_callback,\n                    &render_options,\n                )\n                .await\n                {\n                    Ok(data) => {\n                        Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse {\n                            data,\n                        }))\n                    }\n                    Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                        error: format!(\"Failed to render template data in CLI: {err}\"),\n                    })),\n                }\n            }\n            HostRequest::OpenExternalUrl(open_external_url_request) => {\n                match webbrowser::open(open_external_url_request.url.as_str()) {\n                    Ok(_) => Some(InternalEventPayload::OpenExternalUrlResponse(EmptyPayload {})),\n                    Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                        error: format!(\"Failed to open external URL in CLI: {err}\"),\n                    })),\n                }\n            }\n            HostRequest::CopyText(req) => match copy_text_to_clipboard(req.text.as_str()) {\n                Ok(()) => Some(InternalEventPayload::CopyTextResponse(EmptyPayload {})),\n                Err(error) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                    error: format!(\"Failed to copy text in CLI: {error}\"),\n                })),\n            },\n            HostRequest::PromptText(req) => match prompt_text_for_cli(req) {\n                Ok(value) => {\n                    Some(InternalEventPayload::PromptTextResponse(PromptTextResponse { value }))\n                }\n                Err(error) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                    error: format!(\"Failed to prompt text in CLI: {error}\"),\n                })),\n            },\n            HostRequest::PromptForm(req) => match prompt_form_for_cli(req) {\n                Ok(values) => Some(InternalEventPayload::PromptFormResponse(PromptFormResponse {\n                    values,\n                    done: Some(true),\n                })),\n                Err(error) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                    error: format!(\"Failed to prompt form in CLI: {error}\"),\n                })),\n            },\n            HostRequest::OpenWindow(_) => {\n                Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                    error: \"Unsupported plugin request in CLI: open_window_request\".to_string(),\n                }))\n            }\n            HostRequest::CloseWindow(_) => {\n                Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                    error: \"Unsupported plugin request in CLI: close_window_request\".to_string(),\n                }))\n            }\n            HostRequest::ListCookieNames(_) => {\n                let Some(cookie_jar_id) = execution_context.cookie_jar_id.as_deref() else {\n                    return Some(InternalEventPayload::ListCookieNamesResponse(\n                        ListCookieNamesResponse { names: Vec::new() },\n                    ));\n                };\n\n                let cookie_jar =\n                    match host_context.query_manager.connect().get_cookie_jar(cookie_jar_id) {\n                        Ok(cookie_jar) => cookie_jar,\n                        Err(err) => {\n                            return Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to load cookie jar in CLI: {err}\"),\n                            }));\n                        }\n                    };\n\n                let names = cookie_jar\n                    .cookies\n                    .into_iter()\n                    .filter_map(|c| parse_cookie_name_value(&c.raw_cookie).map(|(name, _)| name))\n                    .collect();\n\n                Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {\n                    names,\n                }))\n            }\n            HostRequest::GetCookieValue(req) => {\n                let Some(cookie_jar_id) = execution_context.cookie_jar_id.as_deref() else {\n                    return Some(InternalEventPayload::GetCookieValueResponse(\n                        GetCookieValueResponse { value: None },\n                    ));\n                };\n\n                let cookie_jar =\n                    match host_context.query_manager.connect().get_cookie_jar(cookie_jar_id) {\n                        Ok(cookie_jar) => cookie_jar,\n                        Err(err) => {\n                            return Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                                error: format!(\"Failed to load cookie jar in CLI: {err}\"),\n                            }));\n                        }\n                    };\n\n                let value = cookie_jar.cookies.into_iter().find_map(|c| {\n                    let (name, value) = parse_cookie_name_value(&c.raw_cookie)?;\n                    if name == req.name { Some(value) } else { None }\n                });\n                Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse { value }))\n            }\n            HostRequest::WindowInfo(req) => {\n                Some(InternalEventPayload::WindowInfoResponse(WindowInfoResponse {\n                    label: req.label.clone(),\n                    request_id: execution_context.request_id.clone(),\n                    workspace_id: execution_context\n                        .workspace_id\n                        .clone()\n                        .or_else(|| event.context.workspace_id.clone()),\n                    environment_id: execution_context.environment_id.clone(),\n                }))\n            }\n            HostRequest::OtherRequest(payload) => {\n                Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                    error: format!(\"Unsupported plugin request in CLI: {}\", payload.type_name()),\n                }))\n            }\n        },\n    }\n}\n\nasync fn render_json_value_for_cli<T: TemplateCallback>(\n    value: Value,\n    environment_chain: Vec<Environment>,\n    cb: &T,\n    opt: &RenderOptions,\n) -> yaak_templates::error::Result<Value> {\n    let vars = &make_vars_hashmap(environment_chain);\n    render_json_value_raw(value, vars, cb, opt).await\n}\n\n\nfn parse_cookie_name_value(raw_cookie: &str) -> Option<(String, String)> {\n    let first_part = raw_cookie.split(';').next()?.trim();\n    let (name, value) = first_part.split_once('=')?;\n    Some((name.trim().to_string(), value.to_string()))\n}\n\nfn copy_text_to_clipboard(text: &str) -> Result<(), String> {\n    let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?;\n    clipboard.set_text(text.to_string()).map_err(|e| e.to_string())\n}\n\nfn prompt_text_for_cli(req: &PromptTextRequest) -> Result<Option<String>, String> {\n    if !std::io::stdin().is_terminal() {\n        return Err(\"cannot prompt in non-interactive mode\".to_string());\n    }\n\n    let term = Term::stdout();\n    if let Some(description) = &req.description {\n        if !description.is_empty() {\n            term.write_line(description.as_str()).map_err(|e| e.to_string())?;\n        }\n    }\n\n    let label = if req.label.is_empty() { req.id.as_str() } else { req.label.as_str() };\n    let value = if req.password.unwrap_or(false) {\n        prompt_password_with_inquire(\n            label,\n            req.default_value.clone(),\n            req.required.unwrap_or(false),\n        )?\n    } else {\n        prompt_text_with_inquire(\n            label,\n            req.default_value.clone(),\n            req.placeholder.clone(),\n            req.required.unwrap_or(false),\n        )?\n    };\n\n    match value {\n        PromptValue::Cancelled => Ok(None),\n        PromptValue::Value(v) => Ok(v),\n    }\n}\n\nfn prompt_form_for_cli(\n    req: &PromptFormRequest,\n) -> Result<Option<HashMap<String, JsonPrimitive>>, String> {\n    if !std::io::stdin().is_terminal() {\n        return Err(\"cannot prompt in non-interactive mode\".to_string());\n    }\n\n    let term = Term::stdout();\n    if let Some(description) = &req.description {\n        if !description.is_empty() {\n            term.write_line(description.as_str()).map_err(|e| e.to_string())?;\n        }\n    }\n\n    let mut values = HashMap::new();\n    for input in &req.inputs {\n        if prompt_form_input_for_cli(input, &mut values)? == PromptOutcome::Cancelled {\n            return Ok(None);\n        }\n    }\n    Ok(Some(values))\n}\n\n#[derive(Clone, Copy, Eq, PartialEq)]\nenum PromptOutcome {\n    Continue,\n    Cancelled,\n}\n\nfn prompt_form_input_for_cli(\n    input: &FormInput,\n    values: &mut HashMap<String, JsonPrimitive>,\n) -> Result<PromptOutcome, String> {\n    match input {\n        FormInput::Text(input) => {\n            if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) {\n                return Ok(PromptOutcome::Continue);\n            }\n\n            let label = prompt_label_for_base(&input.base);\n            let required = !input.base.optional.unwrap_or(false);\n            let value = if input.password.unwrap_or(false) {\n                prompt_password_with_inquire(\n                    label.as_str(),\n                    input.base.default_value.clone(),\n                    required,\n                )?\n            } else {\n                prompt_text_with_inquire(\n                    label.as_str(),\n                    input.base.default_value.clone(),\n                    input.placeholder.clone(),\n                    required,\n                )?\n            };\n\n            match value {\n                PromptValue::Cancelled => Ok(PromptOutcome::Cancelled),\n                PromptValue::Value(Some(v)) => {\n                    values.insert(input.base.name.clone(), JsonPrimitive::String(v));\n                    Ok(PromptOutcome::Continue)\n                }\n                PromptValue::Value(None) => Ok(PromptOutcome::Continue),\n            }\n        }\n        FormInput::Editor(input) => {\n            if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) {\n                return Ok(PromptOutcome::Continue);\n            }\n\n            let label = prompt_label_for_base(&input.base);\n            let required = !input.base.optional.unwrap_or(false);\n            let value = prompt_editor_with_inquire(\n                label.as_str(),\n                input.base.default_value.clone(),\n                required,\n            )?;\n            match value {\n                PromptValue::Cancelled => Ok(PromptOutcome::Cancelled),\n                PromptValue::Value(Some(v)) => {\n                    values.insert(input.base.name.clone(), JsonPrimitive::String(v));\n                    Ok(PromptOutcome::Continue)\n                }\n                PromptValue::Value(None) => Ok(PromptOutcome::Continue),\n            }\n        }\n        FormInput::Select(input) => {\n            if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) {\n                return Ok(PromptOutcome::Continue);\n            }\n\n            let label = prompt_label_for_base(&input.base);\n            let options = input.options.iter().map(|o| o.value.clone()).collect::<Vec<_>>();\n            let value = prompt_select_with_inquire(\n                label.as_str(),\n                options,\n                input.base.default_value.clone(),\n                !input.base.optional.unwrap_or(false),\n            )?;\n            match value {\n                PromptValue::Cancelled => Ok(PromptOutcome::Cancelled),\n                PromptValue::Value(Some(v)) => {\n                    values.insert(input.base.name.clone(), JsonPrimitive::String(v));\n                    Ok(PromptOutcome::Continue)\n                }\n                PromptValue::Value(None) => Ok(PromptOutcome::Continue),\n            }\n        }\n        FormInput::Checkbox(input) => {\n            if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) {\n                return Ok(PromptOutcome::Continue);\n            }\n\n            let label = prompt_label_for_base(&input.base);\n            let default = input\n                .base\n                .default_value\n                .as_deref()\n                .map(|v| matches!(v, \"1\" | \"true\" | \"yes\" | \"on\"))\n                .unwrap_or(false);\n\n            match prompt_confirm_with_inquire(label.as_str(), default)? {\n                PromptValue::Cancelled => Ok(PromptOutcome::Cancelled),\n                PromptValue::Value(Some(v)) => {\n                    values.insert(input.base.name.clone(), JsonPrimitive::Boolean(v == \"true\"));\n                    Ok(PromptOutcome::Continue)\n                }\n                PromptValue::Value(None) => Ok(PromptOutcome::Continue),\n            }\n        }\n        FormInput::File(input) => {\n            if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) {\n                return Ok(PromptOutcome::Continue);\n            }\n\n            let label = prompt_label_for_base(&input.base);\n            let value = prompt_text_with_inquire(\n                label.as_str(),\n                input.base.default_value.clone(),\n                Some(\"Path\".to_string()),\n                !input.base.optional.unwrap_or(false),\n            )?;\n            match value {\n                PromptValue::Cancelled => Ok(PromptOutcome::Cancelled),\n                PromptValue::Value(Some(v)) => {\n                    values.insert(input.base.name.clone(), JsonPrimitive::String(v));\n                    Ok(PromptOutcome::Continue)\n                }\n                PromptValue::Value(None) => Ok(PromptOutcome::Continue),\n            }\n        }\n        FormInput::HttpRequest(input) => {\n            if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) {\n                return Ok(PromptOutcome::Continue);\n            }\n            let label = prompt_label_for_base(&input.base);\n            let value = prompt_text_with_inquire(\n                label.as_str(),\n                input.base.default_value.clone(),\n                Some(\"Request ID\".to_string()),\n                !input.base.optional.unwrap_or(false),\n            )?;\n            match value {\n                PromptValue::Cancelled => Ok(PromptOutcome::Cancelled),\n                PromptValue::Value(Some(v)) => {\n                    values.insert(input.base.name.clone(), JsonPrimitive::String(v));\n                    Ok(PromptOutcome::Continue)\n                }\n                PromptValue::Value(None) => Ok(PromptOutcome::Continue),\n            }\n        }\n        FormInput::KeyValue(input) => {\n            if input.base.hidden.unwrap_or(false) || input.base.disabled.unwrap_or(false) {\n                return Ok(PromptOutcome::Continue);\n            }\n            let label = prompt_label_for_base(&input.base);\n            let value = prompt_text_with_inquire(\n                label.as_str(),\n                input.base.default_value.clone(),\n                Some(\"JSON string\".to_string()),\n                !input.base.optional.unwrap_or(false),\n            )?;\n            match value {\n                PromptValue::Cancelled => Ok(PromptOutcome::Cancelled),\n                PromptValue::Value(Some(v)) => {\n                    values.insert(input.base.name.clone(), JsonPrimitive::String(v));\n                    Ok(PromptOutcome::Continue)\n                }\n                PromptValue::Value(None) => Ok(PromptOutcome::Continue),\n            }\n        }\n        FormInput::Accordion(input) => {\n            if input.hidden.unwrap_or(false) {\n                return Ok(PromptOutcome::Continue);\n            }\n            if let Some(inputs) = &input.inputs {\n                for nested in inputs {\n                    if prompt_form_input_for_cli(nested, values)? == PromptOutcome::Cancelled {\n                        return Ok(PromptOutcome::Cancelled);\n                    }\n                }\n            }\n            Ok(PromptOutcome::Continue)\n        }\n        FormInput::HStack(input) => {\n            if input.hidden.unwrap_or(false) {\n                return Ok(PromptOutcome::Continue);\n            }\n            if let Some(inputs) = &input.inputs {\n                for nested in inputs {\n                    if prompt_form_input_for_cli(nested, values)? == PromptOutcome::Cancelled {\n                        return Ok(PromptOutcome::Cancelled);\n                    }\n                }\n            }\n            Ok(PromptOutcome::Continue)\n        }\n        FormInput::Banner(input) => {\n            if input.hidden.unwrap_or(false) {\n                return Ok(PromptOutcome::Continue);\n            }\n            if let Some(inputs) = &input.inputs {\n                for nested in inputs {\n                    if prompt_form_input_for_cli(nested, values)? == PromptOutcome::Cancelled {\n                        return Ok(PromptOutcome::Cancelled);\n                    }\n                }\n            }\n            Ok(PromptOutcome::Continue)\n        }\n        FormInput::Markdown(input) => {\n            if !input.hidden.unwrap_or(false) && !input.content.trim().is_empty() {\n                let term = Term::stdout();\n                term.write_line(input.content.as_str()).map_err(|e| e.to_string())?;\n            }\n            Ok(PromptOutcome::Continue)\n        }\n    }\n}\n\nenum PromptValue {\n    Value(Option<String>),\n    Cancelled,\n}\n\nfn prompt_text_with_inquire(\n    label: &str,\n    default_value: Option<String>,\n    placeholder: Option<String>,\n    required: bool,\n) -> Result<PromptValue, String> {\n    let default_value = default_value.and_then(|v| {\n        let trimmed = v.trim();\n        if trimmed.is_empty() { None } else { Some(v) }\n    });\n\n    loop {\n        let message = prompt_message(label);\n        let mut prompt = Text::new(message.as_str());\n        if let Some(v) = default_value.as_deref() {\n            prompt = prompt.with_default(v);\n        }\n        if let Some(v) = placeholder.as_deref() {\n            if !v.trim().is_empty() {\n                prompt = prompt.with_placeholder(v);\n            }\n        }\n        let result = prompt.prompt();\n        match result {\n            Ok(v) => {\n                let v = v.trim().to_string();\n                if v.is_empty() {\n                    if let Some(default) = default_value.clone() {\n                        if !default.trim().is_empty() {\n                            return Ok(PromptValue::Value(Some(default)));\n                        }\n                    }\n                    if required {\n                        continue;\n                    }\n                    return Ok(PromptValue::Value(None));\n                }\n                return Ok(PromptValue::Value(Some(v)));\n            }\n            Err(inquire::InquireError::OperationCanceled)\n            | Err(inquire::InquireError::OperationInterrupted) => {\n                return Ok(PromptValue::Cancelled);\n            }\n            Err(err) => return Err(err.to_string()),\n        }\n    }\n}\n\nfn prompt_password_with_inquire(\n    label: &str,\n    default_value: Option<String>,\n    required: bool,\n) -> Result<PromptValue, String> {\n    let default_value = default_value.and_then(|v| {\n        let trimmed = v.trim();\n        if trimmed.is_empty() { None } else { Some(v) }\n    });\n\n    loop {\n        let message = prompt_message(label);\n        let mut prompt = Password::new(message.as_str()).without_confirmation();\n        prompt = prompt.with_display_mode(PasswordDisplayMode::Masked);\n        if default_value.as_ref().is_some_and(|v| !v.trim().is_empty()) {\n            prompt = prompt.with_help_message(\"Leave blank to use the default value\");\n        }\n        let result = prompt.prompt();\n        match result {\n            Ok(v) => {\n                let v = v.trim().to_string();\n                if v.is_empty() {\n                    if let Some(default) = default_value.clone() {\n                        if !default.trim().is_empty() {\n                            return Ok(PromptValue::Value(Some(default)));\n                        }\n                    }\n                    if required {\n                        continue;\n                    }\n                    return Ok(PromptValue::Value(None));\n                }\n                return Ok(PromptValue::Value(Some(v)));\n            }\n            Err(inquire::InquireError::OperationCanceled)\n            | Err(inquire::InquireError::OperationInterrupted) => {\n                return Ok(PromptValue::Cancelled);\n            }\n            Err(err) => return Err(err.to_string()),\n        }\n    }\n}\n\nfn prompt_editor_with_inquire(\n    label: &str,\n    default_value: Option<String>,\n    required: bool,\n) -> Result<PromptValue, String> {\n    loop {\n        let message = prompt_message(label);\n        let mut prompt = Editor::new(message.as_str());\n        if let Some(v) = default_value.as_deref() {\n            prompt = prompt.with_predefined_text(v);\n        }\n        let result = prompt.prompt();\n        match result {\n            Ok(v) => {\n                let v = v.trim().to_string();\n                if v.is_empty() {\n                    if required {\n                        continue;\n                    }\n                    return Ok(PromptValue::Value(None));\n                }\n                return Ok(PromptValue::Value(Some(v)));\n            }\n            Err(inquire::InquireError::OperationCanceled)\n            | Err(inquire::InquireError::OperationInterrupted) => {\n                return Ok(PromptValue::Cancelled);\n            }\n            Err(err) => return Err(err.to_string()),\n        }\n    }\n}\n\nfn prompt_select_with_inquire(\n    label: &str,\n    options: Vec<String>,\n    default_value: Option<String>,\n    required: bool,\n) -> Result<PromptValue, String> {\n    if options.is_empty() {\n        if required {\n            return Err(format!(\"Select input '{label}' has no options\"));\n        }\n        return Ok(PromptValue::Value(None));\n    }\n\n    let index = default_value\n        .as_ref()\n        .and_then(|d| options.iter().position(|o| o == d))\n        .unwrap_or_default();\n\n    let message = prompt_message(label);\n    let mut prompt = Select::new(message.as_str(), options);\n    if default_value.is_some() {\n        prompt = prompt.with_starting_cursor(index);\n    }\n    match prompt.prompt() {\n        Ok(v) => Ok(PromptValue::Value(Some(v))),\n        Err(inquire::InquireError::OperationCanceled)\n        | Err(inquire::InquireError::OperationInterrupted) => Ok(PromptValue::Cancelled),\n        Err(err) => Err(err.to_string()),\n    }\n}\n\nfn prompt_confirm_with_inquire(label: &str, default: bool) -> Result<PromptValue, String> {\n    let message = prompt_message(label);\n    match Confirm::new(message.as_str()).with_default(default).prompt() {\n        Ok(v) => Ok(PromptValue::Value(Some(if v { \"true\" } else { \"false\" }.to_string()))),\n        Err(inquire::InquireError::OperationCanceled)\n        | Err(inquire::InquireError::OperationInterrupted) => Ok(PromptValue::Cancelled),\n        Err(err) => Err(err.to_string()),\n    }\n}\n\nfn prompt_message(label: &str) -> String {\n    format!(\"{label}:\")\n}\n\nfn prompt_label_for_base(base: &yaak_plugins::events::FormInputBase) -> String {\n    if let Some(label) = &base.label {\n        if !label.is_empty() {\n            return label.clone();\n        }\n    }\n    base.name.clone()\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/ui.rs",
    "content": "use console::style;\nuse std::io::{self, IsTerminal};\n\npub fn info(message: &str) {\n    if io::stdout().is_terminal() {\n        println!(\"{:<8} {}\", style(\"INFO\").cyan().bold(), style(message).cyan());\n    } else {\n        println!(\"INFO     {message}\");\n    }\n}\n\npub fn warning(message: &str) {\n    if io::stdout().is_terminal() {\n        println!(\"{:<8} {}\", style(\"WARNING\").yellow().bold(), style(message).yellow());\n    } else {\n        println!(\"WARNING  {message}\");\n    }\n}\n\npub fn warning_stderr(message: &str) {\n    if io::stderr().is_terminal() {\n        eprintln!(\"{:<8} {}\", style(\"WARNING\").yellow().bold(), style(message).yellow());\n    } else {\n        eprintln!(\"WARNING  {message}\");\n    }\n}\n\npub fn success(message: &str) {\n    if io::stdout().is_terminal() {\n        println!(\"{:<8} {}\", style(\"SUCCESS\").green().bold(), style(message).green());\n    } else {\n        println!(\"SUCCESS  {message}\");\n    }\n}\n\npub fn error(message: &str) {\n    if io::stderr().is_terminal() {\n        eprintln!(\"{:<8} {}\", style(\"ERROR\").red().bold(), style(message).red());\n    } else {\n        eprintln!(\"Error: {message}\");\n    }\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/utils/confirm.rs",
    "content": "use std::io::{self, IsTerminal, Write};\n\npub fn confirm_delete(resource_name: &str, resource_id: &str) -> bool {\n    if !io::stdin().is_terminal() {\n        eprintln!(\"Refusing to delete in non-interactive mode without --yes\");\n        std::process::exit(1);\n    }\n\n    print!(\"Delete {resource_name} {resource_id}? [y/N]: \");\n    io::stdout().flush().expect(\"Failed to flush stdout\");\n\n    let mut input = String::new();\n    io::stdin().read_line(&mut input).expect(\"Failed to read confirmation\");\n\n    matches!(input.trim().to_lowercase().as_str(), \"y\" | \"yes\")\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/utils/http.rs",
    "content": "use reqwest::Client;\nuse reqwest::header::{HeaderMap, HeaderName, HeaderValue, USER_AGENT};\nuse serde_json::Value;\n\npub fn build_client(session_token: Option<&str>) -> Result<Client, String> {\n    let mut headers = HeaderMap::new();\n    let user_agent = HeaderValue::from_str(&user_agent())\n        .map_err(|e| format!(\"Failed to build user-agent header: {e}\"))?;\n    headers.insert(USER_AGENT, user_agent);\n\n    if let Some(token) = session_token {\n        let token_value = HeaderValue::from_str(token)\n            .map_err(|e| format!(\"Failed to build session header: {e}\"))?;\n        headers.insert(HeaderName::from_static(\"x-yaak-session\"), token_value);\n    }\n\n    Client::builder()\n        .default_headers(headers)\n        .build()\n        .map_err(|e| format!(\"Failed to initialize HTTP client: {e}\"))\n}\n\npub fn parse_api_error(status: u16, body: &str) -> String {\n    if let Ok(value) = serde_json::from_str::<Value>(body) {\n        if let Some(message) = value.get(\"message\").and_then(Value::as_str) {\n            return message.to_string();\n        }\n        if let Some(error) = value.get(\"error\").and_then(Value::as_str) {\n            return error.to_string();\n        }\n    }\n\n    format!(\"API error {status}: {body}\")\n}\n\nfn user_agent() -> String {\n    format!(\"YaakCli/{} ({})\", crate::version::cli_version(), ua_platform())\n}\n\nfn ua_platform() -> &'static str {\n    match std::env::consts::OS {\n        \"windows\" => \"Win\",\n        \"darwin\" => \"Mac\",\n        \"linux\" => \"Linux\",\n        _ => \"Unknown\",\n    }\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/utils/json.rs",
    "content": "use serde::Serialize;\nuse serde::de::DeserializeOwned;\nuse serde_json::{Map, Value};\n\ntype JsonResult<T> = std::result::Result<T, String>;\n\npub fn is_json_shorthand(input: &str) -> bool {\n    input.trim_start().starts_with('{')\n}\n\npub fn parse_json_object(raw: &str, context: &str) -> JsonResult<Value> {\n    let value: Value = serde_json::from_str(raw)\n        .map_err(|error| format!(\"Invalid JSON for {context}: {error}\"))?;\n\n    if !value.is_object() {\n        return Err(format!(\"JSON payload for {context} must be an object\"));\n    }\n\n    Ok(value)\n}\n\npub fn parse_optional_json(\n    json_flag: Option<String>,\n    json_shorthand: Option<String>,\n    context: &str,\n) -> JsonResult<Option<Value>> {\n    match (json_flag, json_shorthand) {\n        (Some(_), Some(_)) => {\n            Err(format!(\"Cannot provide both --json and positional JSON for {context}\"))\n        }\n        (Some(raw), None) => parse_json_object(&raw, context).map(Some),\n        (None, Some(raw)) => parse_json_object(&raw, context).map(Some),\n        (None, None) => Ok(None),\n    }\n}\n\npub fn parse_required_json(\n    json_flag: Option<String>,\n    json_shorthand: Option<String>,\n    context: &str,\n) -> JsonResult<Value> {\n    parse_optional_json(json_flag, json_shorthand, context)?\n        .ok_or_else(|| format!(\"Missing JSON payload for {context}. Use --json or positional JSON\"))\n}\n\npub fn require_id(payload: &Value, context: &str) -> JsonResult<String> {\n    payload\n        .get(\"id\")\n        .and_then(|value| value.as_str())\n        .filter(|value| !value.is_empty())\n        .map(|value| value.to_string())\n        .ok_or_else(|| format!(\"{context} requires a non-empty \\\"id\\\" field\"))\n}\n\npub fn validate_create_id(payload: &Value, context: &str) -> JsonResult<()> {\n    let Some(id_value) = payload.get(\"id\") else {\n        return Ok(());\n    };\n\n    match id_value {\n        Value::String(id) if id.is_empty() => Ok(()),\n        _ => Err(format!(\"{context} create JSON must omit \\\"id\\\" or set it to an empty string\")),\n    }\n}\n\npub fn merge_workspace_id_arg(\n    workspace_id_from_arg: Option<&str>,\n    payload_workspace_id: &mut String,\n    context: &str,\n) -> JsonResult<()> {\n    if let Some(workspace_id_arg) = workspace_id_from_arg {\n        if payload_workspace_id.is_empty() {\n            *payload_workspace_id = workspace_id_arg.to_string();\n        } else if payload_workspace_id != workspace_id_arg {\n            return Err(format!(\n                \"{context} got conflicting workspace_id values between positional arg and JSON payload\"\n            ));\n        }\n    }\n\n    if payload_workspace_id.is_empty() {\n        return Err(format!(\n            \"{context} requires non-empty \\\"workspaceId\\\" in JSON payload or positional workspace_id\"\n        ));\n    }\n\n    Ok(())\n}\n\npub fn apply_merge_patch<T>(existing: &T, patch: &Value, id: &str, context: &str) -> JsonResult<T>\nwhere\n    T: Serialize + DeserializeOwned,\n{\n    let mut base = serde_json::to_value(existing)\n        .map_err(|error| format!(\"Failed to serialize existing model for {context}: {error}\"))?;\n    merge_patch(&mut base, patch);\n\n    let Some(base_object) = base.as_object_mut() else {\n        return Err(format!(\"Merged payload for {context} must be an object\"));\n    };\n    base_object.insert(\"id\".to_string(), Value::String(id.to_string()));\n\n    serde_json::from_value(base)\n        .map_err(|error| format!(\"Failed to deserialize merged payload for {context}: {error}\"))\n}\n\nfn merge_patch(target: &mut Value, patch: &Value) {\n    match patch {\n        Value::Object(patch_map) => {\n            if !target.is_object() {\n                *target = Value::Object(Map::new());\n            }\n\n            let target_map =\n                target.as_object_mut().expect(\"merge_patch target expected to be object\");\n\n            for (key, patch_value) in patch_map {\n                if patch_value.is_null() {\n                    target_map.remove(key);\n                    continue;\n                }\n\n                let target_entry = target_map.entry(key.clone()).or_insert(Value::Null);\n                merge_patch(target_entry, patch_value);\n            }\n        }\n        _ => {\n            *target = patch.clone();\n        }\n    }\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/utils/mod.rs",
    "content": "pub mod confirm;\npub mod http;\npub mod json;\npub mod schema;\npub mod workspace;\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/utils/schema.rs",
    "content": "use serde_json::{Value, json};\n\npub fn append_agent_hints(schema: &mut Value) {\n    let Some(schema_obj) = schema.as_object_mut() else {\n        return;\n    };\n\n    schema_obj.insert(\n        \"x-yaak-agent-hints\".to_string(),\n        json!({\n            \"templateVariableSyntax\": \"${[ my_var ]}\",\n            \"templateFunctionSyntax\": \"${[ namespace.my_func(a='aaa',b='bbb') ]}\",\n        }),\n    );\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/utils/workspace.rs",
    "content": "use crate::context::CliContext;\n\npub fn resolve_workspace_id(\n    ctx: &CliContext,\n    workspace_id: Option<&str>,\n    command_name: &str,\n) -> Result<String, String> {\n    if let Some(workspace_id) = workspace_id {\n        return Ok(workspace_id.to_string());\n    }\n\n    let workspaces =\n        ctx.db().list_workspaces().map_err(|e| format!(\"Failed to list workspaces: {e}\"))?;\n    match workspaces.as_slice() {\n        [] => Err(format!(\"No workspaces found. {command_name} requires a workspace ID.\")),\n        [workspace] => Ok(workspace.id.clone()),\n        _ => Err(format!(\"Multiple workspaces found. {command_name} requires a workspace ID.\")),\n    }\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/version.rs",
    "content": "pub fn cli_version() -> &'static str {\n    option_env!(\"YAAK_CLI_VERSION\").unwrap_or(env!(\"CARGO_PKG_VERSION\"))\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/src/version_check.rs",
    "content": "use crate::ui;\nuse crate::version;\nuse serde::{Deserialize, Serialize};\nuse std::fs;\nuse std::io::IsTerminal;\nuse std::path::{Path, PathBuf};\nuse std::time::{Duration, SystemTime, UNIX_EPOCH};\nuse yaak_api::{ApiClientKind, yaak_api_client};\n\nconst CACHE_FILE_NAME: &str = \"cli-version-check.json\";\nconst CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;\nconst REQUEST_TIMEOUT: Duration = Duration::from_millis(800);\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\n#[serde(default)]\nstruct VersionCheckResponse {\n    outdated: bool,\n    latest_version: Option<String>,\n    upgrade_hint: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(default)]\nstruct CacheRecord {\n    checked_at_epoch_secs: u64,\n    response: VersionCheckResponse,\n    last_warned_at_epoch_secs: Option<u64>,\n    last_warned_version: Option<String>,\n}\n\nimpl Default for CacheRecord {\n    fn default() -> Self {\n        Self {\n            checked_at_epoch_secs: 0,\n            response: VersionCheckResponse::default(),\n            last_warned_at_epoch_secs: None,\n            last_warned_version: None,\n        }\n    }\n}\n\n#[derive(Debug, Serialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct VersionCheckRequest<'a> {\n    current_version: &'a str,\n    channel: String,\n    install_source: String,\n    platform: &'a str,\n    arch: &'a str,\n}\n\npub async fn maybe_check_for_updates() {\n    if should_skip_check() {\n        return;\n    }\n\n    let now = unix_epoch_secs();\n    let cache_path = cache_path();\n    let cached = read_cache(&cache_path);\n\n    if let Some(cache) = cached.as_ref().filter(|c| !is_expired(c.checked_at_epoch_secs, now)) {\n        let mut record = cache.clone();\n        maybe_warn_outdated(&mut record, now);\n        write_cache(&cache_path, &record);\n        return;\n    }\n\n    let fresh = fetch_version_check().await;\n    match fresh {\n        Some(response) => {\n            let mut record = CacheRecord {\n                checked_at_epoch_secs: now,\n                response: response.clone(),\n                last_warned_at_epoch_secs: cached\n                    .as_ref()\n                    .and_then(|c| c.last_warned_at_epoch_secs),\n                last_warned_version: cached.as_ref().and_then(|c| c.last_warned_version.clone()),\n            };\n            maybe_warn_outdated(&mut record, now);\n            write_cache(&cache_path, &record);\n        }\n        None => {\n            let fallback = cached.as_ref().map(|cache| cache.response.clone()).unwrap_or_default();\n            let mut record = CacheRecord {\n                checked_at_epoch_secs: now,\n                response: fallback,\n                last_warned_at_epoch_secs: cached\n                    .as_ref()\n                    .and_then(|c| c.last_warned_at_epoch_secs),\n                last_warned_version: cached.as_ref().and_then(|c| c.last_warned_version.clone()),\n            };\n            maybe_warn_outdated(&mut record, now);\n            write_cache(&cache_path, &record);\n        }\n    }\n}\n\nfn should_skip_check() -> bool {\n    if std::env::var(\"YAAK_CLI_NO_UPDATE_CHECK\")\n        .is_ok_and(|v| v == \"1\" || v.eq_ignore_ascii_case(\"true\"))\n    {\n        return true;\n    }\n\n    if std::env::var(\"CI\").is_ok() {\n        return true;\n    }\n\n    !std::io::stdout().is_terminal()\n}\n\nasync fn fetch_version_check() -> Option<VersionCheckResponse> {\n    let api_url = format!(\"{}/cli/check\", update_base_url());\n    let current_version = version::cli_version();\n    let payload = VersionCheckRequest {\n        current_version,\n        channel: release_channel(current_version),\n        install_source: install_source(),\n        platform: std::env::consts::OS,\n        arch: std::env::consts::ARCH,\n    };\n\n    let client = yaak_api_client(ApiClientKind::Cli, current_version).ok()?;\n    let request = client.post(api_url).json(&payload);\n\n    let response = tokio::time::timeout(REQUEST_TIMEOUT, request.send()).await.ok()?.ok()?;\n    if !response.status().is_success() {\n        return None;\n    }\n\n    tokio::time::timeout(REQUEST_TIMEOUT, response.json::<VersionCheckResponse>()).await.ok()?.ok()\n}\n\nfn release_channel(version: &str) -> String {\n    version\n        .split_once('-')\n        .and_then(|(_, suffix)| suffix.split('.').next())\n        .unwrap_or(\"stable\")\n        .to_string()\n}\n\nfn install_source() -> String {\n    std::env::var(\"YAAK_CLI_INSTALL_SOURCE\")\n        .ok()\n        .filter(|s| !s.trim().is_empty())\n        .unwrap_or_else(|| \"source\".to_string())\n}\n\nfn update_base_url() -> &'static str {\n    match std::env::var(\"ENVIRONMENT\").ok().as_deref() {\n        Some(\"development\") => \"http://localhost:9444\",\n        _ => \"https://update.yaak.app\",\n    }\n}\n\nfn maybe_warn_outdated(record: &mut CacheRecord, now: u64) {\n    if !record.response.outdated {\n        return;\n    }\n\n    let latest =\n        record.response.latest_version.clone().unwrap_or_else(|| \"a newer release\".to_string());\n    let warn_suppressed = record.last_warned_version.as_deref() == Some(latest.as_str())\n        && record.last_warned_at_epoch_secs.is_some_and(|t| !is_expired(t, now));\n    if warn_suppressed {\n        return;\n    }\n\n    let hint = record.response.upgrade_hint.clone().unwrap_or_else(default_upgrade_hint);\n    ui::warning_stderr(&format!(\"A newer Yaak CLI version is available ({latest}). {hint}\"));\n    record.last_warned_version = Some(latest);\n    record.last_warned_at_epoch_secs = Some(now);\n}\n\nfn default_upgrade_hint() -> String {\n    if install_source() == \"npm\" {\n        let channel = release_channel(version::cli_version());\n        if channel == \"stable\" {\n            return \"Run `npm install -g @yaakapp/cli@latest` to update.\".to_string();\n        }\n        return format!(\"Run `npm install -g @yaakapp/cli@{channel}` to update.\");\n    }\n\n    \"Update your Yaak CLI installation to the latest release.\".to_string()\n}\n\nfn cache_path() -> PathBuf {\n    std::env::temp_dir().join(\"yaak-cli\").join(format!(\"{}-{CACHE_FILE_NAME}\", environment_name()))\n}\n\nfn environment_name() -> &'static str {\n    match std::env::var(\"ENVIRONMENT\").ok().as_deref() {\n        Some(\"staging\") => \"staging\",\n        Some(\"development\") => \"development\",\n        _ => \"production\",\n    }\n}\n\nfn read_cache(path: &Path) -> Option<CacheRecord> {\n    let contents = fs::read_to_string(path).ok()?;\n    serde_json::from_str::<CacheRecord>(&contents).ok()\n}\n\nfn write_cache(path: &Path, record: &CacheRecord) {\n    let Some(parent) = path.parent() else {\n        return;\n    };\n    if fs::create_dir_all(parent).is_err() {\n        return;\n    }\n    let Ok(json) = serde_json::to_string(record) else {\n        return;\n    };\n    let _ = fs::write(path, json);\n}\n\nfn is_expired(checked_at_epoch_secs: u64, now: u64) -> bool {\n    now.saturating_sub(checked_at_epoch_secs) >= CHECK_INTERVAL_SECS\n}\n\nfn unix_epoch_secs() -> u64 {\n    SystemTime::now()\n        .duration_since(UNIX_EPOCH)\n        .unwrap_or_else(|_| Duration::from_secs(0))\n        .as_secs()\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/tests/common/http_server.rs",
    "content": "use std::io::{Read, Write};\nuse std::net::{SocketAddr, TcpListener, TcpStream};\nuse std::sync::Arc;\nuse std::sync::atomic::{AtomicBool, Ordering};\nuse std::thread;\nuse std::time::Duration;\n\npub struct TestHttpServer {\n    pub url: String,\n    addr: SocketAddr,\n    shutdown: Arc<AtomicBool>,\n    handle: Option<thread::JoinHandle<()>>,\n}\n\nimpl TestHttpServer {\n    pub fn spawn_ok(body: &'static str) -> Self {\n        let listener = TcpListener::bind(\"127.0.0.1:0\").expect(\"Failed to bind test HTTP server\");\n        let addr = listener.local_addr().expect(\"Failed to get local addr\");\n        let url = format!(\"http://{addr}/test\");\n        listener.set_nonblocking(true).expect(\"Failed to set test server listener nonblocking\");\n\n        let shutdown = Arc::new(AtomicBool::new(false));\n        let shutdown_signal = Arc::clone(&shutdown);\n        let body_bytes = body.as_bytes().to_vec();\n\n        let handle = thread::spawn(move || {\n            while !shutdown_signal.load(Ordering::Relaxed) {\n                match listener.accept() {\n                    Ok((mut stream, _)) => {\n                        let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));\n                        let mut request_buf = [0u8; 4096];\n                        let _ = stream.read(&mut request_buf);\n\n                        let response = format!(\n                            \"HTTP/1.1 200 OK\\r\\nContent-Type: text/plain\\r\\nContent-Length: {}\\r\\nConnection: close\\r\\n\\r\\n\",\n                            body_bytes.len()\n                        );\n                        let _ = stream.write_all(response.as_bytes());\n                        let _ = stream.write_all(&body_bytes);\n                        let _ = stream.flush();\n                        break;\n                    }\n                    Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {\n                        thread::sleep(Duration::from_millis(10));\n                    }\n                    Err(_) => break,\n                }\n            }\n        });\n\n        Self { url, addr, shutdown, handle: Some(handle) }\n    }\n}\n\nimpl Drop for TestHttpServer {\n    fn drop(&mut self) {\n        self.shutdown.store(true, Ordering::Relaxed);\n        let _ = TcpStream::connect(self.addr);\n\n        if let Some(handle) = self.handle.take() {\n            let _ = handle.join();\n        }\n    }\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/tests/common/mod.rs",
    "content": "#![allow(dead_code)]\n\npub mod http_server;\n\nuse assert_cmd::Command;\nuse assert_cmd::cargo::cargo_bin_cmd;\nuse std::path::Path;\nuse yaak_models::models::{Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace};\nuse yaak_models::query_manager::QueryManager;\nuse yaak_models::util::UpdateSource;\n\npub fn cli_cmd(data_dir: &Path) -> Command {\n    let mut cmd = cargo_bin_cmd!(\"yaak\");\n    cmd.arg(\"--data-dir\").arg(data_dir);\n    cmd\n}\n\npub fn parse_created_id(stdout: &[u8], label: &str) -> String {\n    String::from_utf8_lossy(stdout)\n        .trim()\n        .split_once(\": \")\n        .map(|(_, id)| id.to_string())\n        .unwrap_or_else(|| panic!(\"Expected id in '{label}' output\"))\n}\n\npub fn query_manager(data_dir: &Path) -> QueryManager {\n    let db_path = data_dir.join(\"db.sqlite\");\n    let blob_path = data_dir.join(\"blobs.sqlite\");\n    let (query_manager, _blob_manager, _rx) =\n        yaak_models::init_standalone(&db_path, &blob_path).expect(\"Failed to initialize DB\");\n    query_manager\n}\n\npub fn seed_workspace(data_dir: &Path, workspace_id: &str) {\n    let workspace = Workspace {\n        id: workspace_id.to_string(),\n        name: \"Seed Workspace\".to_string(),\n        description: \"Seeded for integration tests\".to_string(),\n        ..Default::default()\n    };\n\n    query_manager(data_dir)\n        .connect()\n        .upsert_workspace(&workspace, &UpdateSource::Sync)\n        .expect(\"Failed to seed workspace\");\n}\n\npub fn seed_request(data_dir: &Path, workspace_id: &str, request_id: &str) {\n    let request = HttpRequest {\n        id: request_id.to_string(),\n        workspace_id: workspace_id.to_string(),\n        name: \"Seeded Request\".to_string(),\n        method: \"GET\".to_string(),\n        url: \"https://example.com\".to_string(),\n        ..Default::default()\n    };\n\n    query_manager(data_dir)\n        .connect()\n        .upsert_http_request(&request, &UpdateSource::Sync)\n        .expect(\"Failed to seed request\");\n}\n\npub fn seed_folder(data_dir: &Path, workspace_id: &str, folder_id: &str) {\n    let folder = Folder {\n        id: folder_id.to_string(),\n        workspace_id: workspace_id.to_string(),\n        name: \"Seed Folder\".to_string(),\n        ..Default::default()\n    };\n\n    query_manager(data_dir)\n        .connect()\n        .upsert_folder(&folder, &UpdateSource::Sync)\n        .expect(\"Failed to seed folder\");\n}\n\npub fn seed_grpc_request(data_dir: &Path, workspace_id: &str, request_id: &str) {\n    let request = GrpcRequest {\n        id: request_id.to_string(),\n        workspace_id: workspace_id.to_string(),\n        name: \"Seeded gRPC Request\".to_string(),\n        url: \"https://example.com\".to_string(),\n        ..Default::default()\n    };\n\n    query_manager(data_dir)\n        .connect()\n        .upsert_grpc_request(&request, &UpdateSource::Sync)\n        .expect(\"Failed to seed gRPC request\");\n}\n\npub fn seed_websocket_request(data_dir: &Path, workspace_id: &str, request_id: &str) {\n    let request = WebsocketRequest {\n        id: request_id.to_string(),\n        workspace_id: workspace_id.to_string(),\n        name: \"Seeded WebSocket Request\".to_string(),\n        url: \"wss://example.com/socket\".to_string(),\n        ..Default::default()\n    };\n\n    query_manager(data_dir)\n        .connect()\n        .upsert_websocket_request(&request, &UpdateSource::Sync)\n        .expect(\"Failed to seed WebSocket request\");\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/tests/environment_commands.rs",
    "content": "mod common;\n\nuse common::{cli_cmd, parse_created_id, query_manager, seed_workspace};\nuse predicates::str::contains;\nuse tempfile::TempDir;\n\n#[test]\nfn create_list_show_delete_round_trip() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n\n    cli_cmd(data_dir)\n        .args([\"environment\", \"list\", \"wk_test\"])\n        .assert()\n        .success()\n        .stdout(contains(\"Global Variables\"));\n\n    let create_assert = cli_cmd(data_dir)\n        .args([\"environment\", \"create\", \"wk_test\", \"--name\", \"Production\"])\n        .assert()\n        .success();\n    let environment_id = parse_created_id(&create_assert.get_output().stdout, \"environment create\");\n\n    cli_cmd(data_dir)\n        .args([\"environment\", \"list\", \"wk_test\"])\n        .assert()\n        .success()\n        .stdout(contains(&environment_id))\n        .stdout(contains(\"Production\"));\n\n    cli_cmd(data_dir)\n        .args([\"environment\", \"show\", &environment_id])\n        .assert()\n        .success()\n        .stdout(contains(format!(\"\\\"id\\\": \\\"{environment_id}\\\"\")))\n        .stdout(contains(\"\\\"parentModel\\\": \\\"environment\\\"\"));\n\n    cli_cmd(data_dir)\n        .args([\"environment\", \"delete\", &environment_id, \"--yes\"])\n        .assert()\n        .success()\n        .stdout(contains(format!(\"Deleted environment: {environment_id}\")));\n\n    assert!(query_manager(data_dir).connect().get_environment(&environment_id).is_err());\n}\n\n#[test]\nfn json_create_and_update_merge_patch_round_trip() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n\n    let create_assert = cli_cmd(data_dir)\n        .args([\n            \"environment\",\n            \"create\",\n            r#\"{\"workspaceId\":\"wk_test\",\"name\":\"Json Environment\"}\"#,\n        ])\n        .assert()\n        .success();\n    let environment_id = parse_created_id(&create_assert.get_output().stdout, \"environment create\");\n\n    cli_cmd(data_dir)\n        .args([\n            \"environment\",\n            \"update\",\n            &format!(r##\"{{\"id\":\"{}\",\"color\":\"#00ff00\"}}\"##, environment_id),\n        ])\n        .assert()\n        .success()\n        .stdout(contains(format!(\"Updated environment: {environment_id}\")));\n\n    cli_cmd(data_dir)\n        .args([\"environment\", \"show\", &environment_id])\n        .assert()\n        .success()\n        .stdout(contains(\"\\\"name\\\": \\\"Json Environment\\\"\"))\n        .stdout(contains(\"\\\"color\\\": \\\"#00ff00\\\"\"));\n}\n\n#[test]\nfn create_merges_positional_workspace_id_into_json_payload() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n\n    let create_assert = cli_cmd(data_dir)\n        .args([\n            \"environment\",\n            \"create\",\n            \"wk_test\",\n            \"--json\",\n            r#\"{\"name\":\"Merged Environment\"}\"#,\n        ])\n        .assert()\n        .success();\n    let environment_id = parse_created_id(&create_assert.get_output().stdout, \"environment create\");\n\n    cli_cmd(data_dir)\n        .args([\"environment\", \"show\", &environment_id])\n        .assert()\n        .success()\n        .stdout(contains(\"\\\"workspaceId\\\": \\\"wk_test\\\"\"))\n        .stdout(contains(\"\\\"name\\\": \\\"Merged Environment\\\"\"));\n}\n\n#[test]\nfn create_rejects_conflicting_workspace_ids_between_arg_and_json() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n    seed_workspace(data_dir, \"wk_other\");\n\n    cli_cmd(data_dir)\n        .args([\n            \"environment\",\n            \"create\",\n            \"wk_test\",\n            \"--json\",\n            r#\"{\"workspaceId\":\"wk_other\",\"name\":\"Mismatch\"}\"#,\n        ])\n        .assert()\n        .failure()\n        .stderr(contains(\n            \"environment create got conflicting workspace_id values between positional arg and JSON payload\",\n        ));\n}\n\n#[test]\nfn environment_schema_outputs_json_schema() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n\n    cli_cmd(data_dir)\n        .args([\"environment\", \"schema\"])\n        .assert()\n        .success()\n        .stdout(contains(\"\\\"type\\\":\\\"object\\\"\"))\n        .stdout(contains(\"\\\"x-yaak-agent-hints\\\"\"))\n        .stdout(contains(\"\\\"templateVariableSyntax\\\":\\\"${[ my_var ]}\\\"\"))\n        .stdout(contains(\n            \"\\\"templateFunctionSyntax\\\":\\\"${[ namespace.my_func(a='aaa',b='bbb') ]}\\\"\",\n        ))\n        .stdout(contains(\"\\\"workspaceId\\\"\"));\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/tests/folder_commands.rs",
    "content": "mod common;\n\nuse common::{cli_cmd, parse_created_id, query_manager, seed_workspace};\nuse predicates::str::contains;\nuse tempfile::TempDir;\n\n#[test]\nfn create_list_show_delete_round_trip() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n\n    let create_assert = cli_cmd(data_dir)\n        .args([\"folder\", \"create\", \"wk_test\", \"--name\", \"Auth\"])\n        .assert()\n        .success();\n    let folder_id = parse_created_id(&create_assert.get_output().stdout, \"folder create\");\n\n    cli_cmd(data_dir)\n        .args([\"folder\", \"list\", \"wk_test\"])\n        .assert()\n        .success()\n        .stdout(contains(&folder_id))\n        .stdout(contains(\"Auth\"));\n\n    cli_cmd(data_dir)\n        .args([\"folder\", \"show\", &folder_id])\n        .assert()\n        .success()\n        .stdout(contains(format!(\"\\\"id\\\": \\\"{folder_id}\\\"\")))\n        .stdout(contains(\"\\\"workspaceId\\\": \\\"wk_test\\\"\"));\n\n    cli_cmd(data_dir)\n        .args([\"folder\", \"delete\", &folder_id, \"--yes\"])\n        .assert()\n        .success()\n        .stdout(contains(format!(\"Deleted folder: {folder_id}\")));\n\n    assert!(query_manager(data_dir).connect().get_folder(&folder_id).is_err());\n}\n\n#[test]\nfn json_create_and_update_merge_patch_round_trip() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n\n    let create_assert = cli_cmd(data_dir)\n        .args([\n            \"folder\",\n            \"create\",\n            r#\"{\"workspaceId\":\"wk_test\",\"name\":\"Json Folder\"}\"#,\n        ])\n        .assert()\n        .success();\n    let folder_id = parse_created_id(&create_assert.get_output().stdout, \"folder create\");\n\n    cli_cmd(data_dir)\n        .args([\n            \"folder\",\n            \"update\",\n            &format!(r#\"{{\"id\":\"{}\",\"description\":\"Folder Description\"}}\"#, folder_id),\n        ])\n        .assert()\n        .success()\n        .stdout(contains(format!(\"Updated folder: {folder_id}\")));\n\n    cli_cmd(data_dir)\n        .args([\"folder\", \"show\", &folder_id])\n        .assert()\n        .success()\n        .stdout(contains(\"\\\"name\\\": \\\"Json Folder\\\"\"))\n        .stdout(contains(\"\\\"description\\\": \\\"Folder Description\\\"\"));\n}\n\n#[test]\nfn create_merges_positional_workspace_id_into_json_payload() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n\n    let create_assert = cli_cmd(data_dir)\n        .args([\n            \"folder\",\n            \"create\",\n            \"wk_test\",\n            \"--json\",\n            r#\"{\"name\":\"Merged Folder\"}\"#,\n        ])\n        .assert()\n        .success();\n    let folder_id = parse_created_id(&create_assert.get_output().stdout, \"folder create\");\n\n    cli_cmd(data_dir)\n        .args([\"folder\", \"show\", &folder_id])\n        .assert()\n        .success()\n        .stdout(contains(\"\\\"workspaceId\\\": \\\"wk_test\\\"\"))\n        .stdout(contains(\"\\\"name\\\": \\\"Merged Folder\\\"\"));\n}\n\n#[test]\nfn create_rejects_conflicting_workspace_ids_between_arg_and_json() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n    seed_workspace(data_dir, \"wk_other\");\n\n    cli_cmd(data_dir)\n        .args([\n            \"folder\",\n            \"create\",\n            \"wk_test\",\n            \"--json\",\n            r#\"{\"workspaceId\":\"wk_other\",\"name\":\"Mismatch\"}\"#,\n        ])\n        .assert()\n        .failure()\n        .stderr(contains(\n            \"folder create got conflicting workspace_id values between positional arg and JSON payload\",\n        ));\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/tests/request_commands.rs",
    "content": "mod common;\n\nuse common::http_server::TestHttpServer;\nuse common::{\n    cli_cmd, parse_created_id, query_manager, seed_grpc_request, seed_request,\n    seed_websocket_request, seed_workspace,\n};\nuse predicates::str::contains;\nuse tempfile::TempDir;\nuse yaak_models::models::HttpResponseState;\n\n#[test]\nfn show_and_delete_yes_round_trip() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n\n    let create_assert = cli_cmd(data_dir)\n        .args([\n            \"request\",\n            \"create\",\n            \"wk_test\",\n            \"--name\",\n            \"Smoke Test\",\n            \"--url\",\n            \"https://example.com\",\n        ])\n        .assert()\n        .success();\n\n    let request_id = parse_created_id(&create_assert.get_output().stdout, \"request create\");\n\n    cli_cmd(data_dir)\n        .args([\"request\", \"show\", &request_id])\n        .assert()\n        .success()\n        .stdout(contains(format!(\"\\\"id\\\": \\\"{request_id}\\\"\")))\n        .stdout(contains(\"\\\"workspaceId\\\": \\\"wk_test\\\"\"));\n\n    cli_cmd(data_dir)\n        .args([\"request\", \"delete\", &request_id, \"--yes\"])\n        .assert()\n        .success()\n        .stdout(contains(format!(\"Deleted request: {request_id}\")));\n\n    assert!(query_manager(data_dir).connect().get_http_request(&request_id).is_err());\n}\n\n#[test]\nfn delete_without_yes_fails_in_non_interactive_mode() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n    seed_request(data_dir, \"wk_test\", \"rq_seed_delete_noninteractive\");\n\n    cli_cmd(data_dir)\n        .args([\"request\", \"delete\", \"rq_seed_delete_noninteractive\"])\n        .assert()\n        .failure()\n        .code(1)\n        .stderr(contains(\"Refusing to delete in non-interactive mode without --yes\"));\n\n    assert!(\n        query_manager(data_dir).connect().get_http_request(\"rq_seed_delete_noninteractive\").is_ok()\n    );\n}\n\n#[test]\nfn json_create_and_update_merge_patch_round_trip() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n\n    let create_assert = cli_cmd(data_dir)\n        .args([\n            \"request\",\n            \"create\",\n            r#\"{\"workspaceId\":\"wk_test\",\"name\":\"Json Request\",\"url\":\"https://example.com\"}\"#,\n        ])\n        .assert()\n        .success();\n    let request_id = parse_created_id(&create_assert.get_output().stdout, \"request create\");\n\n    cli_cmd(data_dir)\n        .args([\n            \"request\",\n            \"update\",\n            &format!(r#\"{{\"id\":\"{}\",\"name\":\"Renamed Request\"}}\"#, request_id),\n        ])\n        .assert()\n        .success()\n        .stdout(contains(format!(\"Updated request: {request_id}\")));\n\n    cli_cmd(data_dir)\n        .args([\"request\", \"show\", &request_id])\n        .assert()\n        .success()\n        .stdout(contains(\"\\\"name\\\": \\\"Renamed Request\\\"\"))\n        .stdout(contains(\"\\\"url\\\": \\\"https://example.com\\\"\"));\n}\n\n#[test]\nfn update_requires_id_in_json_payload() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n\n    cli_cmd(data_dir)\n        .args([\"request\", \"update\", r#\"{\"name\":\"No ID\"}\"#])\n        .assert()\n        .failure()\n        .stderr(contains(\"request update requires a non-empty \\\"id\\\" field\"));\n}\n\n#[test]\nfn create_allows_workspace_only_with_empty_defaults() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n\n    let create_assert = cli_cmd(data_dir).args([\"request\", \"create\", \"wk_test\"]).assert().success();\n    let request_id = parse_created_id(&create_assert.get_output().stdout, \"request create\");\n\n    let request = query_manager(data_dir)\n        .connect()\n        .get_http_request(&request_id)\n        .expect(\"Failed to load created request\");\n    assert_eq!(request.workspace_id, \"wk_test\");\n    assert_eq!(request.method, \"GET\");\n    assert_eq!(request.name, \"\");\n    assert_eq!(request.url, \"\");\n}\n\n#[test]\nfn create_merges_positional_workspace_id_into_json_payload() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n\n    let create_assert = cli_cmd(data_dir)\n        .args([\n            \"request\",\n            \"create\",\n            \"wk_test\",\n            \"--json\",\n            r#\"{\"name\":\"Merged Request\",\"url\":\"https://example.com\"}\"#,\n        ])\n        .assert()\n        .success();\n    let request_id = parse_created_id(&create_assert.get_output().stdout, \"request create\");\n\n    cli_cmd(data_dir)\n        .args([\"request\", \"show\", &request_id])\n        .assert()\n        .success()\n        .stdout(contains(\"\\\"workspaceId\\\": \\\"wk_test\\\"\"))\n        .stdout(contains(\"\\\"name\\\": \\\"Merged Request\\\"\"));\n}\n\n#[test]\nfn create_rejects_conflicting_workspace_ids_between_arg_and_json() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n    seed_workspace(data_dir, \"wk_other\");\n\n    cli_cmd(data_dir)\n        .args([\n            \"request\",\n            \"create\",\n            \"wk_test\",\n            \"--json\",\n            r#\"{\"workspaceId\":\"wk_other\",\"name\":\"Mismatch\"}\"#,\n        ])\n        .assert()\n        .failure()\n        .stderr(contains(\n            \"request create got conflicting workspace_id values between positional arg and JSON payload\",\n        ));\n}\n\n#[test]\nfn request_send_persists_response_body_and_events() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n\n    let server = TestHttpServer::spawn_ok(\"hello from integration test\");\n\n    let create_assert = cli_cmd(data_dir)\n        .args([\n            \"request\",\n            \"create\",\n            \"wk_test\",\n            \"--name\",\n            \"Send Test\",\n            \"--url\",\n            &server.url,\n        ])\n        .assert()\n        .success();\n    let request_id = parse_created_id(&create_assert.get_output().stdout, \"request create\");\n\n    cli_cmd(data_dir)\n        .args([\"request\", \"send\", &request_id])\n        .assert()\n        .success()\n        .stdout(contains(\"hello from integration test\"));\n\n    let qm = query_manager(data_dir);\n    let db = qm.connect();\n    let responses =\n        db.list_http_responses_for_request(&request_id, None).expect(\"Failed to load responses\");\n    assert_eq!(responses.len(), 1, \"expected exactly one persisted response\");\n\n    let response = &responses[0];\n    assert_eq!(response.status, 200);\n    assert!(matches!(response.state, HttpResponseState::Closed));\n    assert!(response.error.is_none());\n\n    let body_path =\n        response.body_path.as_ref().expect(\"expected persisted response body path\").to_string();\n    let body = std::fs::read_to_string(&body_path).expect(\"Failed to read response body file\");\n    assert_eq!(body, \"hello from integration test\");\n\n    let events =\n        db.list_http_response_events(&response.id).expect(\"Failed to load response events\");\n    assert!(!events.is_empty(), \"expected at least one persisted response event\");\n}\n\n#[test]\nfn request_schema_http_outputs_json_schema() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n\n    cli_cmd(data_dir)\n        .args([\"request\", \"schema\", \"http\"])\n        .assert()\n        .success()\n        .stdout(contains(\"\\\"type\\\":\\\"object\\\"\"))\n        .stdout(contains(\"\\\"x-yaak-agent-hints\\\"\"))\n        .stdout(contains(\"\\\"templateVariableSyntax\\\":\\\"${[ my_var ]}\\\"\"))\n        .stdout(contains(\n            \"\\\"templateFunctionSyntax\\\":\\\"${[ namespace.my_func(a='aaa',b='bbb') ]}\\\"\",\n        ))\n        .stdout(contains(\"\\\"authentication\\\":\"))\n        .stdout(contains(\"/foo/:id/comments/:commentId\"))\n        .stdout(contains(\"put concrete values in `urlParameters`\"));\n}\n\n#[test]\nfn request_schema_http_pretty_prints_with_flag() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n\n    cli_cmd(data_dir)\n        .args([\"request\", \"schema\", \"http\", \"--pretty\"])\n        .assert()\n        .success()\n        .stdout(contains(\"\\\"type\\\": \\\"object\\\"\"))\n        .stdout(contains(\"\\\"authentication\\\"\"));\n}\n\n#[test]\nfn request_send_grpc_returns_explicit_nyi_error() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n    seed_grpc_request(data_dir, \"wk_test\", \"gr_seed_nyi\");\n\n    cli_cmd(data_dir)\n        .args([\"request\", \"send\", \"gr_seed_nyi\"])\n        .assert()\n        .failure()\n        .code(1)\n        .stderr(contains(\"gRPC request send is not implemented yet in yaak-cli\"));\n}\n\n#[test]\nfn request_send_websocket_returns_explicit_nyi_error() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n    seed_websocket_request(data_dir, \"wk_test\", \"wr_seed_nyi\");\n\n    cli_cmd(data_dir)\n        .args([\"request\", \"send\", \"wr_seed_nyi\"])\n        .assert()\n        .failure()\n        .code(1)\n        .stderr(contains(\"WebSocket request send is not implemented yet in yaak-cli\"));\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/tests/send_commands.rs",
    "content": "mod common;\n\nuse common::http_server::TestHttpServer;\nuse common::{cli_cmd, query_manager, seed_folder, seed_workspace};\nuse predicates::str::contains;\nuse tempfile::TempDir;\nuse yaak_models::models::HttpRequest;\nuse yaak_models::util::UpdateSource;\n\n#[test]\nfn top_level_send_workspace_sends_http_requests_and_prints_summary() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n\n    let server = TestHttpServer::spawn_ok(\"workspace bulk send\");\n    let request = HttpRequest {\n        id: \"rq_workspace_send\".to_string(),\n        workspace_id: \"wk_test\".to_string(),\n        name: \"Workspace Send\".to_string(),\n        method: \"GET\".to_string(),\n        url: server.url.clone(),\n        ..Default::default()\n    };\n    query_manager(data_dir)\n        .connect()\n        .upsert_http_request(&request, &UpdateSource::Sync)\n        .expect(\"Failed to seed workspace request\");\n\n    cli_cmd(data_dir)\n        .args([\"send\", \"wk_test\"])\n        .assert()\n        .success()\n        .stdout(contains(\"workspace bulk send\"))\n        .stdout(contains(\"Send summary: 1 succeeded, 0 failed\"));\n}\n\n#[test]\nfn top_level_send_folder_sends_http_requests_and_prints_summary() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n    seed_workspace(data_dir, \"wk_test\");\n    seed_folder(data_dir, \"wk_test\", \"fl_test\");\n\n    let server = TestHttpServer::spawn_ok(\"folder bulk send\");\n    let request = HttpRequest {\n        id: \"rq_folder_send\".to_string(),\n        workspace_id: \"wk_test\".to_string(),\n        folder_id: Some(\"fl_test\".to_string()),\n        name: \"Folder Send\".to_string(),\n        method: \"GET\".to_string(),\n        url: server.url.clone(),\n        ..Default::default()\n    };\n    query_manager(data_dir)\n        .connect()\n        .upsert_http_request(&request, &UpdateSource::Sync)\n        .expect(\"Failed to seed folder request\");\n\n    cli_cmd(data_dir)\n        .args([\"send\", \"fl_test\"])\n        .assert()\n        .success()\n        .stdout(contains(\"folder bulk send\"))\n        .stdout(contains(\"Send summary: 1 succeeded, 0 failed\"));\n}\n\n#[test]\nfn top_level_send_unknown_id_fails_with_clear_error() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n\n    cli_cmd(data_dir)\n        .args([\"send\", \"does_not_exist\"])\n        .assert()\n        .failure()\n        .code(1)\n        .stderr(contains(\"Could not resolve ID 'does_not_exist' as request, folder, or workspace\"));\n}\n"
  },
  {
    "path": "crates-cli/yaak-cli/tests/workspace_commands.rs",
    "content": "mod common;\n\nuse common::{cli_cmd, parse_created_id, query_manager};\nuse predicates::str::contains;\nuse tempfile::TempDir;\n\n#[test]\nfn create_show_delete_round_trip() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n\n    let create_assert =\n        cli_cmd(data_dir).args([\"workspace\", \"create\", \"--name\", \"WS One\"]).assert().success();\n    let workspace_id = parse_created_id(&create_assert.get_output().stdout, \"workspace create\");\n\n    cli_cmd(data_dir)\n        .args([\"workspace\", \"show\", &workspace_id])\n        .assert()\n        .success()\n        .stdout(contains(format!(\"\\\"id\\\": \\\"{workspace_id}\\\"\")))\n        .stdout(contains(\"\\\"name\\\": \\\"WS One\\\"\"));\n\n    cli_cmd(data_dir)\n        .args([\"workspace\", \"delete\", &workspace_id, \"--yes\"])\n        .assert()\n        .success()\n        .stdout(contains(format!(\"Deleted workspace: {workspace_id}\")));\n\n    assert!(query_manager(data_dir).connect().get_workspace(&workspace_id).is_err());\n}\n\n#[test]\nfn json_create_and_update_merge_patch_round_trip() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n\n    let create_assert = cli_cmd(data_dir)\n        .args([\"workspace\", \"create\", r#\"{\"name\":\"Json Workspace\"}\"#])\n        .assert()\n        .success();\n    let workspace_id = parse_created_id(&create_assert.get_output().stdout, \"workspace create\");\n\n    cli_cmd(data_dir)\n        .args([\n            \"workspace\",\n            \"update\",\n            &format!(r#\"{{\"id\":\"{}\",\"description\":\"Updated via JSON\"}}\"#, workspace_id),\n        ])\n        .assert()\n        .success()\n        .stdout(contains(format!(\"Updated workspace: {workspace_id}\")));\n\n    cli_cmd(data_dir)\n        .args([\"workspace\", \"show\", &workspace_id])\n        .assert()\n        .success()\n        .stdout(contains(\"\\\"name\\\": \\\"Json Workspace\\\"\"))\n        .stdout(contains(\"\\\"description\\\": \\\"Updated via JSON\\\"\"));\n}\n\n#[test]\nfn workspace_schema_outputs_json_schema() {\n    let temp_dir = TempDir::new().expect(\"Failed to create temp dir\");\n    let data_dir = temp_dir.path();\n\n    cli_cmd(data_dir)\n        .args([\"workspace\", \"schema\"])\n        .assert()\n        .success()\n        .stdout(contains(\"\\\"type\\\":\\\"object\\\"\"))\n        .stdout(contains(\"\\\"x-yaak-agent-hints\\\"\"))\n        .stdout(contains(\"\\\"templateVariableSyntax\\\":\\\"${[ my_var ]}\\\"\"))\n        .stdout(contains(\n            \"\\\"templateFunctionSyntax\\\":\\\"${[ namespace.my_func(a='aaa',b='bbb') ]}\\\"\",\n        ))\n        .stdout(contains(\"\\\"name\\\"\"));\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/.gitignore",
    "content": "# Generated by Cargo\n# will have compiled files and executables\ntarget/\n\nvendored/*\n\ngen/*\n\n**/permissions/autogenerated\n**/permissions/schemas\n"
  },
  {
    "path": "crates-tauri/yaak-app/Cargo.toml",
    "content": "[package]\nname = \"yaak-app\"\nversion = \"0.0.0\"\nedition = \"2024\"\nauthors = [\"Gregory Schier\"]\npublish = false\n\n# Produce a library for mobile support\n[lib]\nname = \"tauri_app_lib\"\ncrate-type = [\"staticlib\", \"cdylib\", \"lib\"]\n\n[features]\ncargo-clippy = []\ndefault = []\nupdater = []\nlicense = [\"yaak-license\"]\n\n[build-dependencies]\ntauri-build = { version = \"2.5.3\", features = [] }\n\n[target.'cfg(target_os = \"linux\")'.dependencies]\nopenssl-sys = { version = \"0.9.105\", features = [\"vendored\"] } # For Ubuntu installation to work\n\n[dependencies]\ncharset = \"0.1.5\"\nchrono = { workspace = true, features = [\"serde\"] }\ncookie = \"0.18.1\"\neventsource-client = { git = \"https://github.com/yaakapp/rust-eventsource-client\", version = \"0.14.0\" }\nhttp = { version = \"1.2.0\", default-features = false }\nlog = { workspace = true }\nmd5 = \"0.8.0\"\npretty_graphql = \"0.2\"\nr2d2 = \"0.8.10\"\nr2d2_sqlite = \"0.25.0\"\nmime_guess = \"2.0.5\"\nrand = \"0.9.0\"\nreqwest = { workspace = true, features = [\n  \"multipart\",\n  \"gzip\",\n  \"brotli\",\n  \"deflate\",\n  \"json\",\n  \"rustls-tls-manual-roots-no-provider\",\n  \"socks\",\n  \"http2\",\n] }\nserde = { workspace = true, features = [\"derive\"] }\nserde_json = { workspace = true, features = [\"raw_value\"] }\ntauri = { workspace = true, features = [\"devtools\", \"protocol-asset\"] }\ntauri-plugin-clipboard-manager = \"2.3.2\"\ntauri-plugin-deep-link = \"2.4.5\"\ntauri-plugin-dialog = { workspace = true }\ntauri-plugin-fs = \"2.4.4\"\ntauri-plugin-log = { version = \"2.7.1\", features = [\"colored\"] }\ntauri-plugin-opener = \"2.5.2\"\ntauri-plugin-os = \"2.3.2\"\ntauri-plugin-shell = { workspace = true }\ntauri-plugin-single-instance = { version = \"2.3.6\", features = [\"deep-link\"] }\ntauri-plugin-updater = \"2.9.0\"\ntauri-plugin-window-state = \"2.4.1\"\nthiserror = { workspace = true }\ntokio = { workspace = true, features = [\"sync\"] }\ntokio-stream = \"0.1.17\"\ntokio-tungstenite = { version = \"0.26.2\", default-features = false }\nurl = \"2\"\ntokio-util = { version = \"0.7\", features = [\"codec\"] }\nts-rs = { workspace = true }\nuuid = \"1.12.1\"\nyaak-api = { workspace = true }\nyaak-common = { workspace = true }\nyaak-tauri-utils = { workspace = true }\nyaak-core = { workspace = true }\nyaak = { workspace = true }\nyaak-crypto = { workspace = true }\nyaak-fonts = { workspace = true }\nyaak-git = { workspace = true }\nyaak-grpc = { workspace = true }\nyaak-http = { workspace = true }\nyaak-license = { workspace = true, optional = true }\nyaak-mac-window = { workspace = true }\nyaak-models = { workspace = true }\nyaak-plugins = { workspace = true }\nyaak-sse = { workspace = true }\nyaak-sync = { workspace = true }\nyaak-templates = { workspace = true }\nyaak-tls = { workspace = true }\nyaak-ws = { workspace = true }\n"
  },
  {
    "path": "crates-tauri/yaak-app/bindings/gen_watch.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type WatchResult = { unlistenEvent: string, };\n"
  },
  {
    "path": "crates-tauri/yaak-app/bindings/index.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };\n\nexport type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };\n\nexport type UpdateInfo = { replyEventId: string, version: string, downloaded: boolean, };\n\nexport type UpdateResponse = { \"type\": \"ack\" } | { \"type\": \"action\", action: UpdateResponseAction, };\n\nexport type UpdateResponseAction = \"install\" | \"skip\";\n\nexport type WatchResult = { unlistenEvent: string, };\n\nexport type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };\n\nexport type YaakNotificationAction = { label: string, url: string, };\n"
  },
  {
    "path": "crates-tauri/yaak-app/bindings/plugins_ext.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };\n\nexport type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };\n"
  },
  {
    "path": "crates-tauri/yaak-app/build.rs",
    "content": "fn main() {\n    tauri_build::build()\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/capabilities/default.json",
    "content": "{\n  \"identifier\": \"default\",\n  \"description\": \"Default capabilities for all build variants\",\n  \"windows\": [\"*\"],\n  \"permissions\": [\n    \"core:app:allow-identifier\",\n    \"core:event:allow-emit\",\n    \"core:event:allow-listen\",\n    \"core:event:allow-unlisten\",\n    \"core:path:allow-resolve-directory\",\n    \"core:path:allow-basename\",\n    \"os:allow-os-type\",\n    \"clipboard-manager:allow-clear\",\n    \"clipboard-manager:allow-write-text\",\n    \"clipboard-manager:allow-read-text\",\n    \"dialog:allow-open\",\n    \"dialog:allow-save\",\n    \"fs:allow-read-dir\",\n    \"fs:allow-read-file\",\n    \"fs:allow-read-text-file\",\n    {\n      \"identifier\": \"fs:scope\",\n      \"allow\": [\n        {\n          \"path\": \"$APPDATA\"\n        },\n        {\n          \"path\": \"$APPDATA/**\"\n        }\n      ]\n    },\n    \"clipboard-manager:allow-read-text\",\n    \"clipboard-manager:allow-write-text\",\n    \"core:webview:allow-set-webview-zoom\",\n    \"core:window:allow-close\",\n    \"core:window:allow-internal-toggle-maximize\",\n    \"core:window:allow-is-fullscreen\",\n    \"core:window:allow-is-maximized\",\n    \"core:window:allow-maximize\",\n    \"core:window:allow-minimize\",\n    \"core:window:allow-set-decorations\",\n    \"core:window:allow-set-title\",\n    \"core:window:allow-show\",\n    \"core:window:allow-start-dragging\",\n    \"core:window:allow-theme\",\n    \"core:window:allow-unmaximize\",\n    \"opener:allow-default-urls\",\n    \"opener:allow-open-path\",\n    \"opener:allow-open-url\",\n    \"opener:allow-reveal-item-in-dir\",\n    \"shell:allow-open\",\n    \"yaak-fonts:default\",\n    \"yaak-mac-window:default\"\n  ]\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/macos/entitlements.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n    <dict>\n        <!--    Re-enable for sandboxing. Currently disabled because auto-updater doesn't work with sandboxing.-->\n        <!--    <key>com.apple.security.app-sandbox</key> <true/>-->\n        <!--    <key>com.apple.security.files.user-selected.read-write</key> <true/>-->\n        <!--    <key>com.apple.security.network.client</key> <true/>-->\n    </dict>\n</plist>\n"
  },
  {
    "path": "crates-tauri/yaak-app/macos/entitlements.yaaknode.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n    <dict>\n        <!-- Enable for NodeJS/V8 JIT compiler -->\n        <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n        <true/>\n\n        <!-- Allow loading plugins signed with different Team IDs (e.g., 1Password) -->\n        <key>com.apple.security.cs.disable-library-validation</key>\n        <true/>\n    </dict>\n</plist>\n"
  },
  {
    "path": "crates-tauri/yaak-app/macos/entitlements.yaakprotoc.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n    <dict>\n    </dict>\n</plist>\n"
  },
  {
    "path": "crates-tauri/yaak-app/package.json",
    "content": "{\n  \"name\": \"@yaakapp-internal/tauri\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"bindings/index.ts\"\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/commands.rs",
    "content": "use crate::PluginContextExt;\nuse crate::error::Result;\nuse std::sync::Arc;\nuse tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command};\nuse yaak_crypto::manager::EncryptionManager;\nuse yaak_models::models::HttpRequestHeader;\nuse yaak_models::queries::workspaces::default_headers;\nuse yaak_plugins::events::GetThemesResponse;\nuse yaak_plugins::manager::PluginManager;\nuse yaak_plugins::native_template_functions::{\n    decrypt_secure_template_function, encrypt_secure_template_function,\n};\n\n/// Extension trait for accessing the EncryptionManager from Tauri Manager types.\npub trait EncryptionManagerExt<'a, R> {\n    fn crypto(&'a self) -> State<'a, EncryptionManager>;\n}\n\nimpl<'a, R: Runtime, M: Manager<R>> EncryptionManagerExt<'a, R> for M {\n    fn crypto(&'a self) -> State<'a, EncryptionManager> {\n        self.state::<EncryptionManager>()\n    }\n}\n\n#[command]\npub(crate) async fn cmd_decrypt_template<R: Runtime>(\n    window: WebviewWindow<R>,\n    template: &str,\n) -> Result<String> {\n    let encryption_manager = window.app_handle().state::<EncryptionManager>();\n    let plugin_context = window.plugin_context();\n    Ok(decrypt_secure_template_function(&encryption_manager, &plugin_context, template)?)\n}\n\n#[command]\npub(crate) async fn cmd_secure_template<R: Runtime>(\n    app_handle: AppHandle<R>,\n    window: WebviewWindow<R>,\n    template: &str,\n) -> Result<String> {\n    let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());\n    let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());\n    let plugin_context = window.plugin_context();\n    Ok(encrypt_secure_template_function(\n        plugin_manager,\n        encryption_manager,\n        &plugin_context,\n        template,\n    )?)\n}\n\n#[command]\npub(crate) async fn cmd_get_themes<R: Runtime>(\n    window: WebviewWindow<R>,\n    plugin_manager: State<'_, PluginManager>,\n) -> Result<Vec<GetThemesResponse>> {\n    Ok(plugin_manager.get_themes(&window.plugin_context()).await?)\n}\n\n#[command]\npub(crate) async fn cmd_enable_encryption<R: Runtime>(\n    window: WebviewWindow<R>,\n    workspace_id: &str,\n) -> Result<()> {\n    window.crypto().ensure_workspace_key(workspace_id)?;\n    window.crypto().reveal_workspace_key(workspace_id)?;\n    Ok(())\n}\n\n#[command]\npub(crate) async fn cmd_reveal_workspace_key<R: Runtime>(\n    window: WebviewWindow<R>,\n    workspace_id: &str,\n) -> Result<String> {\n    Ok(window.crypto().reveal_workspace_key(workspace_id)?)\n}\n\n#[command]\npub(crate) async fn cmd_set_workspace_key<R: Runtime>(\n    window: WebviewWindow<R>,\n    workspace_id: &str,\n    key: &str,\n) -> Result<()> {\n    window.crypto().set_human_key(workspace_id, key)?;\n    Ok(())\n}\n\n#[command]\npub(crate) async fn cmd_disable_encryption<R: Runtime>(\n    window: WebviewWindow<R>,\n    workspace_id: &str,\n) -> Result<()> {\n    window.crypto().disable_encryption(workspace_id)?;\n    Ok(())\n}\n\n#[command]\npub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> {\n    default_headers()\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/encoding.rs",
    "content": "use mime_guess::{Mime, mime};\nuse std::path::Path;\nuse std::str::FromStr;\nuse tokio::fs;\n\npub async fn read_response_body(body_path: impl AsRef<Path>, content_type: &str) -> Option<String> {\n    let body = fs::read(body_path).await.ok()?;\n    let body_charset = parse_charset(content_type).unwrap_or(\"utf-8\".to_string());\n    if let Some(decoder) = charset::Charset::for_label(body_charset.as_bytes()) {\n        let (cow, _real_encoding, _exist_replace) = decoder.decode(&body);\n        return cow.into_owned().into();\n    }\n\n    Some(String::from_utf8_lossy(&body).to_string())\n}\n\nfn parse_charset(content_type: &str) -> Option<String> {\n    let mime: Mime = Mime::from_str(content_type).ok()?;\n    mime.get_param(mime::CHARSET).map(|v| v.to_string())\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/error.rs",
    "content": "use serde::{Serialize, Serializer};\nuse std::io;\nuse thiserror::Error;\n\n#[derive(Error, Debug)]\npub enum Error {\n    #[error(transparent)]\n    TemplateError(#[from] yaak_templates::error::Error),\n\n    #[error(transparent)]\n    ModelError(#[from] yaak_models::error::Error),\n\n    #[error(transparent)]\n    SyncError(#[from] yaak_sync::error::Error),\n\n    #[error(transparent)]\n    CryptoError(#[from] yaak_crypto::error::Error),\n\n    #[error(transparent)]\n    HttpError(#[from] yaak_http::error::Error),\n\n    #[error(transparent)]\n    GitError(#[from] yaak_git::error::Error),\n\n    #[error(transparent)]\n    TokioTimeoutElapsed(#[from] tokio::time::error::Elapsed),\n\n    #[error(transparent)]\n    WebsocketError(#[from] yaak_ws::error::Error),\n\n    #[cfg(feature = \"license\")]\n    #[error(transparent)]\n    LicenseError(#[from] yaak_license::error::Error),\n\n    #[error(transparent)]\n    PluginError(#[from] yaak_plugins::error::Error),\n\n    #[error(transparent)]\n    ApiError(#[from] yaak_api::Error),\n\n    #[error(transparent)]\n    ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),\n\n    #[error(transparent)]\n    OpenerError(#[from] tauri_plugin_opener::Error),\n\n    #[error(\"Updater error: {0}\")]\n    UpdaterError(#[from] tauri_plugin_updater::Error),\n\n    #[error(\"JSON error: {0}\")]\n    JsonError(#[from] serde_json::error::Error),\n\n    #[error(\"Tauri error: {0}\")]\n    TauriError(#[from] tauri::Error),\n\n    #[error(\"Event source error: {0}\")]\n    EventSourceError(#[from] eventsource_client::Error),\n\n    #[error(\"I/O error: {0}\")]\n    IOError(#[from] io::Error),\n\n    #[error(\"Request error: {0}\")]\n    RequestError(#[from] reqwest::Error),\n\n    #[error(\"{0}\")]\n    GenericError(String),\n}\n\nimpl Serialize for Error {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        serializer.serialize_str(self.to_string().as_ref())\n    }\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/git_ext.rs",
    "content": "//! Tauri-specific extensions for yaak-git.\n//!\n//! This module provides the Tauri commands for git functionality.\n\nuse crate::error::Result;\nuse std::path::{Path, PathBuf};\nuse tauri::command;\nuse yaak_git::{\n    BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,\n    PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,\n    git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all,\n    git_init, git_log, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge, git_push,\n    git_remotes, git_rename_branch, git_reset_changes, git_rm_remote, git_status, git_unstage,\n};\n\n// NOTE: All of these commands are async to prevent blocking work from locking up the UI\n\n#[command]\npub async fn cmd_git_checkout(dir: &Path, branch: &str, force: bool) -> Result<String> {\n    Ok(git_checkout_branch(dir, branch, force).await?)\n}\n\n#[command]\npub async fn cmd_git_branch(dir: &Path, branch: &str, base: Option<&str>) -> Result<()> {\n    Ok(git_create_branch(dir, branch, base).await?)\n}\n\n#[command]\npub async fn cmd_git_delete_branch(\n    dir: &Path,\n    branch: &str,\n    force: Option<bool>,\n) -> Result<BranchDeleteResult> {\n    Ok(git_delete_branch(dir, branch, force.unwrap_or(false)).await?)\n}\n\n#[command]\npub async fn cmd_git_delete_remote_branch(dir: &Path, branch: &str) -> Result<()> {\n    Ok(git_delete_remote_branch(dir, branch).await?)\n}\n\n#[command]\npub async fn cmd_git_merge_branch(dir: &Path, branch: &str) -> Result<()> {\n    Ok(git_merge_branch(dir, branch).await?)\n}\n\n#[command]\npub async fn cmd_git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> {\n    Ok(git_rename_branch(dir, old_name, new_name).await?)\n}\n\n#[command]\npub async fn cmd_git_status(dir: &Path) -> Result<GitStatusSummary> {\n    Ok(git_status(dir)?)\n}\n\n#[command]\npub async fn cmd_git_log(dir: &Path) -> Result<Vec<GitCommit>> {\n    Ok(git_log(dir)?)\n}\n\n#[command]\npub async fn cmd_git_initialize(dir: &Path) -> Result<()> {\n    Ok(git_init(dir)?)\n}\n\n#[command]\npub async fn cmd_git_clone(url: &str, dir: &Path) -> Result<CloneResult> {\n    Ok(git_clone(url, dir).await?)\n}\n\n#[command]\npub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> {\n    Ok(git_commit(dir, message).await?)\n}\n\n#[command]\npub async fn cmd_git_fetch_all(dir: &Path) -> Result<()> {\n    Ok(git_fetch_all(dir).await?)\n}\n\n#[command]\npub async fn cmd_git_push(dir: &Path) -> Result<PushResult> {\n    Ok(git_push(dir).await?)\n}\n\n#[command]\npub async fn cmd_git_pull(dir: &Path) -> Result<PullResult> {\n    Ok(git_pull(dir).await?)\n}\n\n#[command]\npub async fn cmd_git_pull_force_reset(\n    dir: &Path,\n    remote: &str,\n    branch: &str,\n) -> Result<PullResult> {\n    Ok(git_pull_force_reset(dir, remote, branch).await?)\n}\n\n#[command]\npub async fn cmd_git_pull_merge(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {\n    Ok(git_pull_merge(dir, remote, branch).await?)\n}\n\n#[command]\npub async fn cmd_git_add(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {\n    for path in rela_paths {\n        git_add(dir, &path)?;\n    }\n    Ok(())\n}\n\n#[command]\npub async fn cmd_git_unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {\n    for path in rela_paths {\n        git_unstage(dir, &path)?;\n    }\n    Ok(())\n}\n\n#[command]\npub async fn cmd_git_reset_changes(dir: &Path) -> Result<()> {\n    Ok(git_reset_changes(dir).await?)\n}\n\n#[command]\npub async fn cmd_git_add_credential(\n    remote_url: &str,\n    username: &str,\n    password: &str,\n) -> Result<()> {\n    Ok(git_add_credential(remote_url, username, password).await?)\n}\n\n#[command]\npub async fn cmd_git_remotes(dir: &Path) -> Result<Vec<GitRemote>> {\n    Ok(git_remotes(dir)?)\n}\n\n#[command]\npub async fn cmd_git_add_remote(dir: &Path, name: &str, url: &str) -> Result<GitRemote> {\n    Ok(git_add_remote(dir, name, url)?)\n}\n\n#[command]\npub async fn cmd_git_rm_remote(dir: &Path, name: &str) -> Result<()> {\n    Ok(git_rm_remote(dir, name)?)\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/grpc.rs",
    "content": "use std::collections::BTreeMap;\n\nuse crate::PluginContextExt;\nuse crate::error::Result;\nuse crate::models_ext::QueryManagerExt;\nuse KeyAndValueRef::{Ascii, Binary};\nuse tauri::{Manager, Runtime, WebviewWindow};\nuse yaak_grpc::{KeyAndValueRef, MetadataMap};\nuse yaak_models::models::GrpcRequest;\nuse yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader};\nuse yaak_plugins::manager::PluginManager;\n\npub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String> {\n    let mut entries = BTreeMap::new();\n    for r in metadata.iter() {\n        match r {\n            Ascii(k, v) => entries.insert(k.to_string(), v.to_str().unwrap().to_string()),\n            Binary(k, v) => entries.insert(k.to_string(), format!(\"{:?}\", v)),\n        };\n    }\n    entries\n}\n\npub(crate) fn resolve_grpc_request<R: Runtime>(\n    window: &WebviewWindow<R>,\n    request: &GrpcRequest,\n) -> Result<(GrpcRequest, String)> {\n    let mut new_request = request.clone();\n\n    let (authentication_type, authentication, authentication_context_id) =\n        window.db().resolve_auth_for_grpc_request(request)?;\n    new_request.authentication_type = authentication_type;\n    new_request.authentication = authentication;\n\n    let metadata = window.db().resolve_metadata_for_grpc_request(request)?;\n    new_request.metadata = metadata;\n\n    Ok((new_request, authentication_context_id))\n}\n\npub(crate) async fn build_metadata<R: Runtime>(\n    window: &WebviewWindow<R>,\n    request: &GrpcRequest,\n    authentication_context_id: &str,\n) -> Result<BTreeMap<String, String>> {\n    let plugin_manager = window.state::<PluginManager>();\n    let mut metadata = BTreeMap::new();\n\n    // Add the rest of metadata\n    for h in request.metadata.clone() {\n        if h.name.is_empty() && h.value.is_empty() {\n            continue;\n        }\n\n        if !h.enabled {\n            continue;\n        }\n\n        metadata.insert(h.name, h.value);\n    }\n\n    match request.authentication_type.clone() {\n        None => {\n            // No authentication found. Not even inherited\n        }\n        Some(authentication_type) if authentication_type == \"none\" => {\n            // Explicitly no authentication\n        }\n        Some(authentication_type) => {\n            let auth = request.authentication.clone();\n            let plugin_req = CallHttpAuthenticationRequest {\n                context_id: format!(\"{:x}\", md5::compute(authentication_context_id)),\n                values: serde_json::from_value(serde_json::to_value(&auth)?)?,\n                method: \"POST\".to_string(),\n                url: request.url.clone(),\n                headers: metadata\n                    .iter()\n                    .map(|(name, value)| HttpHeader {\n                        name: name.to_string(),\n                        value: value.to_string(),\n                    })\n                    .collect(),\n            };\n            let plugin_result = plugin_manager\n                .call_http_authentication(\n                    &window.plugin_context(),\n                    &authentication_type,\n                    plugin_req,\n                )\n                .await?;\n            for header in plugin_result.set_headers.unwrap_or_default() {\n                metadata.insert(header.name, header.value);\n            }\n        }\n    }\n\n    Ok(metadata)\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/history.rs",
    "content": "use crate::models_ext::QueryManagerExt;\nuse chrono::{NaiveDateTime, Utc};\nuse log::debug;\nuse std::sync::OnceLock;\nuse tauri::{AppHandle, Runtime};\nuse yaak_models::util::UpdateSource;\n\nconst NAMESPACE: &str = \"analytics\";\nconst NUM_LAUNCHES_KEY: &str = \"num_launches\";\nconst LAST_VERSION_KEY: &str = \"last_tracked_version\";\nconst PREV_VERSION_KEY: &str = \"last_tracked_version_prev\";\nconst VERSION_SINCE_KEY: &str = \"last_tracked_version_since\";\n\n#[derive(Default, Debug, Clone)]\npub struct LaunchEventInfo {\n    pub current_version: String,\n    pub previous_version: String,\n    pub launched_after_update: bool,\n    pub version_since: NaiveDateTime,\n    pub user_since: NaiveDateTime,\n    pub num_launches: i32,\n}\n\nstatic LAUNCH_INFO: OnceLock<LaunchEventInfo> = OnceLock::new();\n\npub fn get_or_upsert_launch_info<R: Runtime>(app_handle: &AppHandle<R>) -> &LaunchEventInfo {\n    LAUNCH_INFO.get_or_init(|| {\n        let now = Utc::now().naive_utc();\n        let mut info = LaunchEventInfo {\n            version_since: app_handle.db().get_key_value_dte(NAMESPACE, VERSION_SINCE_KEY, now),\n            current_version: app_handle.package_info().version.to_string(),\n            user_since: app_handle.db().get_settings().created_at,\n            num_launches: app_handle.db().get_key_value_int(NAMESPACE, NUM_LAUNCHES_KEY, 0) + 1,\n\n            // The rest will be set below\n            ..Default::default()\n        };\n\n        app_handle\n            .with_tx(|tx| {\n                // Load the previously tracked version\n                let curr_db = tx.get_key_value_str(NAMESPACE, LAST_VERSION_KEY, \"\");\n                let prev_db = tx.get_key_value_str(NAMESPACE, PREV_VERSION_KEY, \"\");\n\n                // We just updated if the app version is different from the last tracked version we stored\n                if !curr_db.is_empty() && info.current_version != curr_db {\n                    info.launched_after_update = true;\n                }\n\n                // If we just updated, track the previous version as the \"previous\" current version\n                if info.launched_after_update {\n                    info.previous_version = curr_db.clone();\n                    info.version_since = now;\n                } else {\n                    info.previous_version = prev_db.clone();\n                }\n\n                // Rotate stored versions: move previous into the \"prev\" slot before overwriting\n                let source = &UpdateSource::Background;\n\n                tx.set_key_value_str(NAMESPACE, PREV_VERSION_KEY, &info.previous_version, source);\n                tx.set_key_value_str(NAMESPACE, LAST_VERSION_KEY, &info.current_version, source);\n                tx.set_key_value_dte(NAMESPACE, VERSION_SINCE_KEY, info.version_since, source);\n                tx.set_key_value_int(NAMESPACE, NUM_LAUNCHES_KEY, info.num_launches, source);\n\n                Ok(())\n            })\n            .unwrap();\n\n        debug!(\"Initialized launch info\");\n\n        info\n    })\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/http_request.rs",
    "content": "use crate::PluginContextExt;\nuse crate::error::Error::GenericError;\nuse crate::error::Result;\nuse crate::models_ext::BlobManagerExt;\nuse crate::models_ext::QueryManagerExt;\nuse log::warn;\nuse std::sync::Arc;\nuse std::time::Instant;\nuse tauri::{AppHandle, Manager, Runtime, WebviewWindow};\nuse tokio::sync::watch::Receiver;\nuse yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};\nuse yaak_crypto::manager::EncryptionManager;\nuse yaak_http::manager::HttpConnectionManager;\nuse yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};\nuse yaak_models::util::UpdateSource;\nuse yaak_plugins::events::PluginContext;\nuse yaak_plugins::manager::PluginManager;\n\n/// Context for managing response state during HTTP transactions.\n/// Handles both persisted responses (stored in DB) and ephemeral responses (in-memory only).\nstruct ResponseContext<R: Runtime> {\n    app_handle: AppHandle<R>,\n    response: HttpResponse,\n    update_source: UpdateSource,\n}\n\nimpl<R: Runtime> ResponseContext<R> {\n    fn new(app_handle: AppHandle<R>, response: HttpResponse, update_source: UpdateSource) -> Self {\n        Self { app_handle, response, update_source }\n    }\n\n    /// Whether this response is persisted (has a non-empty ID)\n    fn is_persisted(&self) -> bool {\n        !self.response.id.is_empty()\n    }\n\n    /// Update the response state. For persisted responses, fetches from DB, applies the\n    /// closure, and updates the DB. For ephemeral responses, just applies the closure\n    /// to the in-memory response.\n    fn update<F>(&mut self, func: F) -> Result<()>\n    where\n        F: FnOnce(&mut HttpResponse),\n    {\n        if self.is_persisted() {\n            let r = self.app_handle.with_tx(|tx| {\n                let mut r = tx.get_http_response(&self.response.id)?;\n                func(&mut r);\n                tx.update_http_response_if_id(&r, &self.update_source)?;\n                Ok(r)\n            })?;\n            self.response = r;\n            Ok(())\n        } else {\n            func(&mut self.response);\n            Ok(())\n        }\n    }\n\n    /// Get the current response state\n    fn response(&self) -> &HttpResponse {\n        &self.response\n    }\n}\n\npub async fn send_http_request<R: Runtime>(\n    window: &WebviewWindow<R>,\n    unrendered_request: &HttpRequest,\n    og_response: &HttpResponse,\n    environment: Option<Environment>,\n    cookie_jar: Option<CookieJar>,\n    cancelled_rx: &mut Receiver<bool>,\n) -> Result<HttpResponse> {\n    send_http_request_with_context(\n        window,\n        unrendered_request,\n        og_response,\n        environment,\n        cookie_jar,\n        cancelled_rx,\n        &window.plugin_context(),\n    )\n    .await\n}\n\npub async fn send_http_request_with_context<R: Runtime>(\n    window: &WebviewWindow<R>,\n    unrendered_request: &HttpRequest,\n    og_response: &HttpResponse,\n    environment: Option<Environment>,\n    cookie_jar: Option<CookieJar>,\n    cancelled_rx: &Receiver<bool>,\n    plugin_context: &PluginContext,\n) -> Result<HttpResponse> {\n    let app_handle = window.app_handle().clone();\n    let update_source = UpdateSource::from_window_label(window.label());\n    let mut response_ctx =\n        ResponseContext::new(app_handle.clone(), og_response.clone(), update_source);\n\n    // Execute the inner send logic and handle errors consistently\n    let start = Instant::now();\n    let result = send_http_request_inner(\n        window,\n        unrendered_request,\n        environment,\n        cookie_jar,\n        cancelled_rx,\n        plugin_context,\n        &mut response_ctx,\n    )\n    .await;\n\n    match result {\n        Ok(response) => Ok(response),\n        Err(e) => {\n            let error = e.to_string();\n            let elapsed = start.elapsed().as_millis() as i32;\n            warn!(\"Failed to send request: {error:?}\");\n            let _ = response_ctx.update(|r| {\n                r.state = HttpResponseState::Closed;\n                r.elapsed = elapsed;\n                if r.elapsed_headers == 0 {\n                    r.elapsed_headers = elapsed;\n                }\n                r.error = Some(error);\n            });\n            Ok(response_ctx.response().clone())\n        }\n    }\n}\n\nasync fn send_http_request_inner<R: Runtime>(\n    window: &WebviewWindow<R>,\n    unrendered_request: &HttpRequest,\n    environment: Option<Environment>,\n    cookie_jar: Option<CookieJar>,\n    cancelled_rx: &Receiver<bool>,\n    plugin_context: &PluginContext,\n    response_ctx: &mut ResponseContext<R>,\n) -> Result<HttpResponse> {\n    let app_handle = window.app_handle().clone();\n    let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());\n    let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());\n    let connection_manager = app_handle.state::<HttpConnectionManager>();\n    let environment_id = environment.map(|e| e.id);\n    let cookie_jar_id = cookie_jar.as_ref().map(|jar| jar.id.clone());\n\n    let response_dir = app_handle.path().app_data_dir()?.join(\"responses\");\n    let result = send_http_request_with_plugins(SendHttpRequestWithPluginsParams {\n        query_manager: app_handle.db_manager().inner(),\n        blob_manager: app_handle.blob_manager().inner(),\n        request: unrendered_request.clone(),\n        environment_id: environment_id.as_deref(),\n        update_source: response_ctx.update_source.clone(),\n        cookie_jar_id,\n        response_dir: &response_dir,\n        emit_events_to: None,\n        emit_response_body_chunks_to: None,\n        existing_response: Some(response_ctx.response().clone()),\n        plugin_manager,\n        encryption_manager,\n        plugin_context,\n        cancelled_rx: Some(cancelled_rx.clone()),\n        connection_manager: Some(connection_manager.inner()),\n    })\n    .await\n    .map_err(|e| GenericError(e.to_string()))?;\n\n    Ok(result.response)\n}\n\npub fn resolve_http_request<R: Runtime>(\n    window: &WebviewWindow<R>,\n    request: &HttpRequest,\n) -> Result<(HttpRequest, String)> {\n    let mut new_request = request.clone();\n\n    let (authentication_type, authentication, authentication_context_id) =\n        window.db().resolve_auth_for_http_request(request)?;\n    new_request.authentication_type = authentication_type;\n    new_request.authentication = authentication;\n\n    let headers = window.db().resolve_headers_for_http_request(request)?;\n    new_request.headers = headers;\n\n    Ok((new_request, authentication_context_id))\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/import.rs",
    "content": "use crate::PluginContextExt;\nuse crate::error::Result;\nuse crate::models_ext::QueryManagerExt;\nuse log::info;\nuse std::collections::BTreeMap;\nuse std::fs::read_to_string;\nuse tauri::{Manager, Runtime, WebviewWindow};\nuse yaak_core::WorkspaceContext;\nuse yaak_models::models::{\n    Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,\n};\nuse yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};\nuse yaak_plugins::manager::PluginManager;\nuse yaak_tauri_utils::window::WorkspaceWindowTrait;\n\npub(crate) async fn import_data<R: Runtime>(\n    window: &WebviewWindow<R>,\n    file_path: &str,\n) -> Result<BatchUpsertResult> {\n    let plugin_manager = window.state::<PluginManager>();\n    let file =\n        read_to_string(file_path).unwrap_or_else(|_| panic!(\"Unable to read file {}\", file_path));\n    let file_contents = file.as_str();\n    let import_result = plugin_manager.import_data(&window.plugin_context(), file_contents).await?;\n\n    let mut id_map: BTreeMap<String, String> = BTreeMap::new();\n\n    // Create WorkspaceContext from window\n    let ctx = WorkspaceContext {\n        workspace_id: window.workspace_id(),\n        environment_id: window.environment_id(),\n        cookie_jar_id: window.cookie_jar_id(),\n        request_id: None,\n    };\n\n    let resources = import_result.resources;\n\n    let workspaces: Vec<Workspace> = resources\n        .workspaces\n        .into_iter()\n        .map(|mut v| {\n            v.id = maybe_gen_id::<Workspace>(&ctx, v.id.as_str(), &mut id_map);\n            v\n        })\n        .collect();\n\n    let environments: Vec<Environment> = resources\n        .environments\n        .into_iter()\n        .map(|mut v| {\n            v.id = maybe_gen_id::<Environment>(&ctx, v.id.as_str(), &mut id_map);\n            v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);\n            match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {\n                (\"folder\", Some(parent_id)) => {\n                    v.parent_id = Some(maybe_gen_id::<Folder>(&ctx, &parent_id, &mut id_map));\n                }\n                (\"\", _) => {\n                    // Fix any empty ones\n                    v.parent_model = \"workspace\".to_string();\n                }\n                _ => {\n                    // Parent ID only required for the folder case\n                    v.parent_id = None;\n                }\n            };\n            v\n        })\n        .collect();\n\n    let folders: Vec<Folder> = resources\n        .folders\n        .into_iter()\n        .map(|mut v| {\n            v.id = maybe_gen_id::<Folder>(&ctx, v.id.as_str(), &mut id_map);\n            v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);\n            v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);\n            v\n        })\n        .collect();\n\n    let http_requests: Vec<HttpRequest> = resources\n        .http_requests\n        .into_iter()\n        .map(|mut v| {\n            v.id = maybe_gen_id::<HttpRequest>(&ctx, v.id.as_str(), &mut id_map);\n            v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);\n            v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);\n            v\n        })\n        .collect();\n\n    let grpc_requests: Vec<GrpcRequest> = resources\n        .grpc_requests\n        .into_iter()\n        .map(|mut v| {\n            v.id = maybe_gen_id::<GrpcRequest>(&ctx, v.id.as_str(), &mut id_map);\n            v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);\n            v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);\n            v\n        })\n        .collect();\n\n    let websocket_requests: Vec<WebsocketRequest> = resources\n        .websocket_requests\n        .into_iter()\n        .map(|mut v| {\n            v.id = maybe_gen_id::<WebsocketRequest>(&ctx, v.id.as_str(), &mut id_map);\n            v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);\n            v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);\n            v\n        })\n        .collect();\n\n    info!(\"Importing data\");\n\n    let upserted = window.with_tx(|tx| {\n        tx.batch_upsert(\n            workspaces,\n            environments,\n            folders,\n            http_requests,\n            grpc_requests,\n            websocket_requests,\n            &UpdateSource::Import,\n        )\n    })?;\n\n    Ok(upserted)\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/lib.rs",
    "content": "extern crate core;\nuse crate::encoding::read_response_body;\nuse crate::error::Error::GenericError;\nuse crate::error::Result;\nuse crate::grpc::{build_metadata, metadata_to_map, resolve_grpc_request};\nuse crate::http_request::{resolve_http_request, send_http_request};\nuse crate::import::import_data;\nuse crate::models_ext::{BlobManagerExt, QueryManagerExt};\nuse crate::notifications::YaakNotifier;\nuse crate::render::{render_grpc_request, render_json_value, render_template};\nuse crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};\nuse crate::uri_scheme::handle_deep_link;\nuse error::Result as YaakResult;\nuse eventsource_client::{EventParser, SSE};\nuse log::{debug, error, info, warn};\nuse std::collections::HashMap;\nuse std::fs::File;\nuse std::path::PathBuf;\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse std::time::Duration;\nuse std::{fs, panic};\nuse tauri::path::BaseDirectory;\nuse tauri::{AppHandle, Emitter, RunEvent, State, WebviewWindow, is_dev};\nuse tauri::{Listener, Runtime};\nuse tauri::{Manager, WindowEvent};\nuse tauri_plugin_deep_link::DeepLinkExt;\nuse tauri_plugin_log::fern::colors::ColoredLevelConfig;\nuse tauri_plugin_log::{Builder, Target, TargetKind, log};\nuse tauri_plugin_window_state::{AppHandleExt, StateFlags};\nuse tokio::sync::Mutex;\nuse tokio::task::block_in_place;\nuse tokio::time;\nuse yaak_common::command::new_checked_command;\nuse yaak_crypto::manager::EncryptionManager;\nuse yaak_grpc::manager::{GrpcConfig, GrpcHandle};\nuse yaak_templates::strip_json_comments::strip_json_comments;\nuse yaak_grpc::{Code, ServiceDefinition, serialize_message};\nuse yaak_mac_window::AppHandleMacWindowExt;\nuse yaak_models::models::{\n    AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,\n    GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,\n    WorkspaceMeta,\n};\nuse yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};\nuse yaak_plugins::events::{\n    CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,\n    CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,\n    CallWebsocketRequestActionArgs, CallWebsocketRequestActionRequest, CallWorkspaceActionArgs,\n    CallWorkspaceActionRequest, Color, FilterResponse, GetFolderActionsResponse,\n    GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,\n    GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,\n    GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse,\n    GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, InternalEvent,\n    InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,\n};\nuse yaak_plugins::manager::PluginManager;\nuse yaak_plugins::plugin_meta::PluginMetadata;\nuse yaak_plugins::template_callback::PluginTemplateCallback;\nuse yaak_sse::sse::ServerSentEvent;\nuse yaak_tauri_utils::window::WorkspaceWindowTrait;\nuse yaak_templates::format_json::format_json;\nuse yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args};\nuse yaak_tls::find_client_certificate;\n\nmod commands;\nmod encoding;\nmod error;\nmod git_ext;\nmod grpc;\nmod history;\nmod http_request;\nmod import;\nmod models_ext;\nmod notifications;\nmod plugin_events;\nmod plugins_ext;\nmod render;\nmod sync_ext;\nmod updates;\nmod uri_scheme;\nmod window;\nmod window_menu;\nmod ws_ext;\n\n/// Extension trait for easily creating a PluginContext from a WebviewWindow\npub trait PluginContextExt<R: Runtime> {\n    fn plugin_context(&self) -> PluginContext;\n}\n\nimpl<R: Runtime> PluginContextExt<R> for WebviewWindow<R> {\n    fn plugin_context(&self) -> PluginContext {\n        PluginContext::new(Some(self.label().to_string()), self.workspace_id())\n    }\n}\n\n#[derive(serde::Serialize)]\n#[serde(default, rename_all = \"camelCase\")]\nstruct AppMetaData {\n    is_dev: bool,\n    version: String,\n    cli_version: Option<String>,\n    name: String,\n    app_data_dir: String,\n    app_log_dir: String,\n    vendored_plugin_dir: String,\n    default_project_dir: String,\n    feature_updater: bool,\n    feature_license: bool,\n}\n\n#[tauri::command]\nasync fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {\n    let app_data_dir = app_handle.path().app_data_dir()?;\n    let app_log_dir = app_handle.path().app_log_dir()?;\n    let vendored_plugin_dir =\n        app_handle.path().resolve(\"vendored/plugins\", BaseDirectory::Resource)?;\n    let default_project_dir = app_handle.path().home_dir()?.join(\"YaakProjects\");\n    let cli_version = detect_cli_version().await;\n    Ok(AppMetaData {\n        is_dev: is_dev(),\n        version: app_handle.package_info().version.to_string(),\n        cli_version,\n        name: app_handle.package_info().name.to_string(),\n        app_data_dir: app_data_dir.to_string_lossy().to_string(),\n        app_log_dir: app_log_dir.to_string_lossy().to_string(),\n        vendored_plugin_dir: vendored_plugin_dir.to_string_lossy().to_string(),\n        default_project_dir: default_project_dir.to_string_lossy().to_string(),\n        feature_license: cfg!(feature = \"license\"),\n        feature_updater: cfg!(feature = \"updater\"),\n    })\n}\n\nasync fn detect_cli_version() -> Option<String> {\n    detect_cli_version_for_binary(\"yaak\").await\n}\n\nasync fn detect_cli_version_for_binary(program: &str) -> Option<String> {\n    let mut cmd = new_checked_command(program, \"--version\").await.ok()?;\n    let out = cmd.arg(\"--version\").output().await.ok()?;\n    if !out.status.success() {\n        return None;\n    }\n\n    let line = String::from_utf8(out.stdout).ok()?;\n    let line = line.lines().find(|l| !l.trim().is_empty())?.trim();\n    let mut parts = line.split_whitespace();\n    let _name = parts.next();\n    Some(parts.next().unwrap_or(line).to_string())\n}\n\n#[tauri::command]\nasync fn cmd_template_tokens_to_string<R: Runtime>(\n    window: WebviewWindow<R>,\n    app_handle: AppHandle<R>,\n    tokens: Tokens,\n) -> YaakResult<String> {\n    let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());\n    let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());\n    let cb = PluginTemplateCallback::new(\n        plugin_manager,\n        encryption_manager,\n        &PluginContext::new(Some(window.label().to_string()), window.workspace_id()),\n        RenderPurpose::Preview,\n    );\n    let new_tokens = transform_args(tokens, &cb)?;\n    Ok(new_tokens.to_string())\n}\n\n#[tauri::command]\nasync fn cmd_render_template<R: Runtime>(\n    window: WebviewWindow<R>,\n    app_handle: AppHandle<R>,\n    template: &str,\n    workspace_id: &str,\n    environment_id: Option<&str>,\n    purpose: Option<RenderPurpose>,\n    ignore_error: Option<bool>,\n) -> YaakResult<String> {\n    let environment_chain =\n        app_handle.db().resolve_environments(workspace_id, None, environment_id)?;\n    let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());\n    let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());\n    let result = render_template(\n        template,\n        environment_chain,\n        &PluginTemplateCallback::new(\n            plugin_manager,\n            encryption_manager,\n            &PluginContext::new(Some(window.label().to_string()), window.workspace_id()),\n            purpose.unwrap_or(RenderPurpose::Preview),\n        ),\n        &RenderOptions {\n            error_behavior: match ignore_error {\n                Some(true) => RenderErrorBehavior::ReturnEmpty,\n                _ => RenderErrorBehavior::Throw,\n            },\n        },\n    )\n    .await?;\n    Ok(result)\n}\n\n#[tauri::command]\nasync fn cmd_dismiss_notification<R: Runtime>(\n    window: WebviewWindow<R>,\n    notification_id: &str,\n    yaak_notifier: State<'_, Mutex<YaakNotifier>>,\n) -> YaakResult<()> {\n    Ok(yaak_notifier.lock().await.seen(&window, notification_id).await?)\n}\n\n#[tauri::command]\nasync fn cmd_grpc_reflect<R: Runtime>(\n    request_id: &str,\n    environment_id: Option<&str>,\n    proto_files: Vec<String>,\n    window: WebviewWindow<R>,\n    app_handle: AppHandle<R>,\n    grpc_handle: State<'_, Mutex<GrpcHandle>>,\n) -> YaakResult<Vec<ServiceDefinition>> {\n    let unrendered_request = app_handle.db().get_grpc_request(request_id)?;\n    let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;\n\n    let environment_chain = app_handle.db().resolve_environments(\n        &unrendered_request.workspace_id,\n        unrendered_request.folder_id.as_deref(),\n        environment_id,\n    )?;\n    let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;\n\n    let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());\n    let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());\n    let req = render_grpc_request(\n        &resolved_request,\n        environment_chain,\n        &PluginTemplateCallback::new(\n            plugin_manager,\n            encryption_manager,\n            &PluginContext::new(Some(window.label().to_string()), window.workspace_id()),\n            RenderPurpose::Send,\n        ),\n        &RenderOptions { error_behavior: RenderErrorBehavior::Throw },\n    )\n    .await?;\n\n    let uri = safe_uri(&req.url);\n    let metadata = build_metadata(&window, &req, &auth_context_id).await?;\n    let settings = window.db().get_settings();\n    let client_certificate =\n        find_client_certificate(req.url.as_str(), &settings.client_certificates);\n    let proto_files: Vec<PathBuf> =\n        proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect();\n\n    // Always invalidate cached pool when this command is called, to force re-reflection\n    let mut handle = grpc_handle.lock().await;\n    handle.invalidate_pool(&req.id, &uri, &proto_files);\n\n    Ok(handle\n        .services(\n            &req.id,\n            &uri,\n            &proto_files,\n            &metadata,\n            workspace.setting_validate_certificates,\n            client_certificate,\n        )\n        .await\n        .map_err(|e| GenericError(e.to_string()))?)\n}\n\n#[tauri::command]\nasync fn cmd_grpc_go<R: Runtime>(\n    request_id: &str,\n    environment_id: Option<&str>,\n    proto_files: Vec<String>,\n    app_handle: AppHandle<R>,\n    window: WebviewWindow<R>,\n    grpc_handle: State<'_, Mutex<GrpcHandle>>,\n) -> YaakResult<String> {\n    let unrendered_request = app_handle.db().get_grpc_request(request_id)?;\n    let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;\n    let environment_chain = app_handle.db().resolve_environments(\n        &unrendered_request.workspace_id,\n        unrendered_request.folder_id.as_deref(),\n        environment_id,\n    )?;\n    let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;\n\n    let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());\n    let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());\n    let request = render_grpc_request(\n        &resolved_request,\n        environment_chain.clone(),\n        &PluginTemplateCallback::new(\n            plugin_manager.clone(),\n            encryption_manager.clone(),\n            &PluginContext::new(Some(window.label().to_string()), window.workspace_id()),\n            RenderPurpose::Send,\n        ),\n        &RenderOptions { error_behavior: RenderErrorBehavior::Throw },\n    )\n    .await?;\n\n    let metadata = build_metadata(&window, &request, &auth_context_id).await?;\n\n    // Find matching client certificate for this URL\n    let settings = app_handle.db().get_settings();\n    let client_cert = find_client_certificate(&request.url, &settings.client_certificates);\n\n    let conn = app_handle.db().upsert_grpc_connection(\n        &GrpcConnection {\n            workspace_id: request.workspace_id.clone(),\n            request_id: request.id.clone(),\n            status: -1,\n            elapsed: 0,\n            state: GrpcConnectionState::Initialized,\n            url: request.url.clone(),\n            ..Default::default()\n        },\n        &UpdateSource::from_window_label(window.label()),\n    )?;\n\n    let conn_id = conn.id.clone();\n\n    let base_msg = GrpcEvent {\n        workspace_id: request.clone().workspace_id,\n        request_id: request.clone().id,\n        connection_id: conn.clone().id,\n        ..Default::default()\n    };\n\n    let (in_msg_tx, in_msg_rx) = tauri::async_runtime::channel::<String>(16);\n    let maybe_in_msg_tx = std::sync::Mutex::new(Some(in_msg_tx.clone()));\n    let (cancelled_tx, mut cancelled_rx) = tokio::sync::watch::channel(false);\n\n    let uri = safe_uri(&request.url);\n\n    let in_msg_stream = tokio_stream::wrappers::ReceiverStream::new(in_msg_rx);\n\n    let (service, method) = {\n        let req = request.clone();\n        match (req.service, req.method) {\n            (Some(service), Some(method)) => (service, method),\n            _ => return Err(GenericError(\"Service and method are required\".to_string())),\n        }\n    };\n\n    let start = std::time::Instant::now();\n    let connection = grpc_handle\n        .lock()\n        .await\n        .connect(\n            &request.clone().id,\n            uri.as_str(),\n            &proto_files.iter().map(|p| PathBuf::from_str(p).unwrap()).collect(),\n            &metadata,\n            workspace.setting_validate_certificates,\n            client_cert.clone(),\n        )\n        .await;\n\n    let connection = match connection {\n        Ok(c) => c,\n        Err(err) => {\n            app_handle.db().upsert_grpc_connection(\n                &GrpcConnection {\n                    elapsed: start.elapsed().as_millis() as i32,\n                    error: Some(err.to_string()),\n                    state: GrpcConnectionState::Closed,\n                    ..conn.clone()\n                },\n                &UpdateSource::from_window_label(window.label()),\n            )?;\n            return Ok(conn_id);\n        }\n    };\n\n    let method_desc =\n        connection.method(&service, &method).await.map_err(|e| GenericError(e.to_string()))?;\n\n    #[derive(serde::Deserialize)]\n    enum IncomingMsg {\n        Message(String),\n        Cancel,\n        Commit,\n    }\n\n    let cb = {\n        let cancelled_rx = cancelled_rx.clone();\n        let environment_chain = environment_chain.clone();\n        let window = window.clone();\n        let plugin_manager = plugin_manager.clone();\n        let encryption_manager = encryption_manager.clone();\n\n        move |ev: tauri::Event| {\n            if *cancelled_rx.borrow() {\n                // Stream is canceled\n                return;\n            }\n\n            let mut maybe_in_msg_tx = maybe_in_msg_tx.lock().expect(\"previous holder not to panic\");\n            let in_msg_tx = if let Some(in_msg_tx) = maybe_in_msg_tx.as_ref() {\n                in_msg_tx\n            } else {\n                // This would mean that the stream is already committed because\n                // we have already dropped the sending half\n                return;\n            };\n\n            match serde_json::from_str::<IncomingMsg>(ev.payload()) {\n                Ok(IncomingMsg::Message(msg)) => {\n                    let window = window.clone();\n                    let environment_chain = environment_chain.clone();\n                    let plugin_manager = plugin_manager.clone();\n                    let encryption_manager = encryption_manager.clone();\n                    let msg = block_in_place(|| {\n                        tauri::async_runtime::block_on(async {\n                            let result = render_template(\n                                msg.as_str(),\n                                environment_chain,\n                                &PluginTemplateCallback::new(\n                                    plugin_manager,\n                                    encryption_manager,\n                                    &PluginContext::new(\n                                        Some(window.label().to_string()),\n                                        window.workspace_id(),\n                                    ),\n                                    RenderPurpose::Send,\n                                ),\n                                &RenderOptions { error_behavior: RenderErrorBehavior::Throw },\n                            )\n                            .await;\n                            result.expect(\"Failed to render template\")\n                        })\n                    });\n                    let msg = strip_json_comments(&msg);\n                    in_msg_tx.try_send(msg.clone()).unwrap();\n                }\n                Ok(IncomingMsg::Commit) => {\n                    maybe_in_msg_tx.take();\n                }\n                Ok(IncomingMsg::Cancel) => {\n                    cancelled_tx.send_replace(true);\n                }\n                Err(e) => {\n                    error!(\"Failed to parse gRPC message: {:?}\", e);\n                }\n            }\n        }\n    };\n    let event_handler = app_handle.listen_any(format!(\"grpc_client_msg_{}\", conn.id).as_str(), cb);\n\n    let grpc_listen = {\n        let window = window.clone();\n        let app_handle = app_handle.clone();\n        let base_event = base_msg.clone();\n        let environment_chain = environment_chain.clone();\n        let req = request.clone();\n        let msg = if req.message.is_empty() { \"{}\".to_string() } else { req.message };\n        let msg = render_template(\n            msg.as_str(),\n            environment_chain,\n            &PluginTemplateCallback::new(\n                plugin_manager.clone(),\n                encryption_manager.clone(),\n                &PluginContext::new(Some(window.label().to_string()), window.workspace_id()),\n                RenderPurpose::Send,\n            ),\n            &RenderOptions { error_behavior: RenderErrorBehavior::Throw },\n        )\n        .await?;\n        let msg = strip_json_comments(&msg);\n\n        app_handle.db().upsert_grpc_event(\n            &GrpcEvent {\n                content: format!(\"Connecting to {}\", req.url),\n                event_type: GrpcEventType::ConnectionStart,\n                metadata: metadata.clone(),\n                ..base_event.clone()\n            },\n            &UpdateSource::from_window_label(window.label()),\n        )?;\n\n        async move {\n            // Create callback for streaming methods that handles both success and error\n            let on_message = {\n                let app_handle = app_handle.clone();\n                let base_event = base_event.clone();\n                let window_label = window.label().to_string();\n                move |result: std::result::Result<String, String>| match result {\n                    Ok(msg) => {\n                        let _ = app_handle.db().upsert_grpc_event(\n                            &GrpcEvent {\n                                content: msg,\n                                event_type: GrpcEventType::ClientMessage,\n                                ..base_event.clone()\n                            },\n                            &UpdateSource::from_window_label(&window_label),\n                        );\n                    }\n                    Err(error) => {\n                        let _ = app_handle.db().upsert_grpc_event(\n                            &GrpcEvent {\n                                content: format!(\"Failed to send message: {}\", error),\n                                event_type: GrpcEventType::Error,\n                                ..base_event.clone()\n                            },\n                            &UpdateSource::from_window_label(&window_label),\n                        );\n                    }\n                }\n            };\n\n            let (maybe_stream, maybe_msg) =\n                match (method_desc.is_client_streaming(), method_desc.is_server_streaming()) {\n                    (true, true) => (\n                        Some(\n                            connection\n                                .streaming(\n                                    &service,\n                                    &method,\n                                    in_msg_stream,\n                                    &metadata,\n                                    client_cert,\n                                    on_message.clone(),\n                                )\n                                .await,\n                        ),\n                        None,\n                    ),\n                    (true, false) => (\n                        None,\n                        Some(\n                            connection\n                                .client_streaming(\n                                    &service,\n                                    &method,\n                                    in_msg_stream,\n                                    &metadata,\n                                    client_cert,\n                                    on_message.clone(),\n                                )\n                                .await,\n                        ),\n                    ),\n                    (false, true) => (\n                        Some(connection.server_streaming(&service, &method, &msg, &metadata).await),\n                        None,\n                    ),\n                    (false, false) => (\n                        None,\n                        Some(\n                            connection.unary(&service, &method, &msg, &metadata, client_cert).await,\n                        ),\n                    ),\n                };\n\n            if !method_desc.is_client_streaming() {\n                app_handle\n                    .db()\n                    .upsert_grpc_event(\n                        &GrpcEvent {\n                            event_type: GrpcEventType::ClientMessage,\n                            content: msg,\n                            ..base_event.clone()\n                        },\n                        &UpdateSource::from_window_label(window.label()),\n                    )\n                    .unwrap();\n            }\n\n            match maybe_msg {\n                Some(Ok(msg)) => {\n                    app_handle\n                        .db()\n                        .upsert_grpc_event(\n                            &GrpcEvent {\n                                metadata: metadata_to_map(msg.metadata().clone()),\n                                content: if msg.metadata().len() == 0 {\n                                    \"Received response\"\n                                } else {\n                                    \"Received response with metadata\"\n                                }\n                                .to_string(),\n                                event_type: GrpcEventType::Info,\n                                ..base_event.clone()\n                            },\n                            &UpdateSource::from_window_label(window.label()),\n                        )\n                        .unwrap();\n                    app_handle\n                        .db()\n                        .upsert_grpc_event(\n                            &GrpcEvent {\n                                content: serialize_message(&msg.into_inner()).unwrap(),\n                                event_type: GrpcEventType::ServerMessage,\n                                ..base_event.clone()\n                            },\n                            &UpdateSource::from_window_label(window.label()),\n                        )\n                        .unwrap();\n                    app_handle\n                        .db()\n                        .upsert_grpc_event(\n                            &GrpcEvent {\n                                content: \"Connection complete\".to_string(),\n                                event_type: GrpcEventType::ConnectionEnd,\n                                status: Some(Code::Ok as i32),\n                                ..base_event.clone()\n                            },\n                            &UpdateSource::from_window_label(window.label()),\n                        )\n                        .unwrap();\n                }\n                Some(Err(yaak_grpc::error::Error::GrpcStreamError(e))) => {\n                    app_handle\n                        .db()\n                        .upsert_grpc_event(\n                            &(match e.status {\n                                Some(s) => GrpcEvent {\n                                    error: Some(s.message().to_string()),\n                                    status: Some(s.code() as i32),\n                                    content: \"Failed to connect\".to_string(),\n                                    metadata: metadata_to_map(s.metadata().clone()),\n                                    event_type: GrpcEventType::ConnectionEnd,\n                                    ..base_event.clone()\n                                },\n                                None => GrpcEvent {\n                                    error: Some(e.message),\n                                    status: Some(Code::Unknown as i32),\n                                    content: \"Failed to connect\".to_string(),\n                                    event_type: GrpcEventType::ConnectionEnd,\n                                    ..base_event.clone()\n                                },\n                            }),\n                            &UpdateSource::from_window_label(window.label()),\n                        )\n                        .unwrap();\n                }\n                Some(Err(e)) => {\n                    app_handle\n                        .db()\n                        .upsert_grpc_event(\n                            &GrpcEvent {\n                                error: Some(e.to_string()),\n                                status: Some(Code::Unknown as i32),\n                                content: \"Failed to connect\".to_string(),\n                                event_type: GrpcEventType::ConnectionEnd,\n                                ..base_event.clone()\n                            },\n                            &UpdateSource::from_window_label(window.label()),\n                        )\n                        .unwrap();\n                }\n                None => {\n                    // Server streaming doesn't return the initial message\n                }\n            }\n\n            let mut stream = match maybe_stream {\n                Some(Ok(stream)) => {\n                    app_handle\n                        .db()\n                        .upsert_grpc_event(\n                            &GrpcEvent {\n                                metadata: metadata_to_map(stream.metadata().clone()),\n                                content: if stream.metadata().len() == 0 {\n                                    \"Received response\"\n                                } else {\n                                    \"Received response with metadata\"\n                                }\n                                .to_string(),\n                                event_type: GrpcEventType::Info,\n                                ..base_event.clone()\n                            },\n                            &UpdateSource::from_window_label(window.label()),\n                        )\n                        .unwrap();\n                    stream.into_inner()\n                }\n                Some(Err(yaak_grpc::error::Error::GrpcStreamError(e))) => {\n                    warn!(\"GRPC stream error {e:?}\");\n                    app_handle\n                        .db()\n                        .upsert_grpc_event(\n                            &(match e.status {\n                                Some(s) => GrpcEvent {\n                                    error: Some(s.message().to_string()),\n                                    status: Some(s.code() as i32),\n                                    content: \"Failed to connect\".to_string(),\n                                    metadata: metadata_to_map(s.metadata().clone()),\n                                    event_type: GrpcEventType::ConnectionEnd,\n                                    ..base_event.clone()\n                                },\n                                None => GrpcEvent {\n                                    error: Some(e.message),\n                                    status: Some(Code::Unknown as i32),\n                                    content: \"Failed to connect\".to_string(),\n                                    event_type: GrpcEventType::ConnectionEnd,\n                                    ..base_event.clone()\n                                },\n                            }),\n                            &UpdateSource::from_window_label(window.label()),\n                        )\n                        .unwrap();\n                    return;\n                }\n                Some(Err(e)) => {\n                    app_handle\n                        .db()\n                        .upsert_grpc_event(\n                            &GrpcEvent {\n                                error: Some(e.to_string()),\n                                status: Some(Code::Unknown as i32),\n                                content: \"Failed to connect\".to_string(),\n                                event_type: GrpcEventType::ConnectionEnd,\n                                ..base_event.clone()\n                            },\n                            &UpdateSource::from_window_label(window.label()),\n                        )\n                        .unwrap();\n                    return;\n                }\n                None => return,\n            };\n\n            loop {\n                match stream.message().await {\n                    Ok(Some(msg)) => {\n                        let message = serialize_message(&msg).unwrap();\n                        app_handle\n                            .db()\n                            .upsert_grpc_event(\n                                &GrpcEvent {\n                                    content: message,\n                                    event_type: GrpcEventType::ServerMessage,\n                                    ..base_event.clone()\n                                },\n                                &UpdateSource::from_window_label(window.label()),\n                            )\n                            .unwrap();\n                    }\n                    Ok(None) => {\n                        let trailers =\n                            stream.trailers().await.unwrap_or_default().unwrap_or_default();\n                        app_handle\n                            .db()\n                            .upsert_grpc_event(\n                                &GrpcEvent {\n                                    content: \"Connection complete\".to_string(),\n                                    status: Some(Code::Ok as i32),\n                                    metadata: metadata_to_map(trailers),\n                                    event_type: GrpcEventType::ConnectionEnd,\n                                    ..base_event.clone()\n                                },\n                                &UpdateSource::from_window_label(window.label()),\n                            )\n                            .unwrap();\n                        break;\n                    }\n                    Err(status) => {\n                        app_handle\n                            .db()\n                            .upsert_grpc_event(\n                                &GrpcEvent {\n                                    content: status.to_string(),\n                                    status: Some(status.code() as i32),\n                                    metadata: metadata_to_map(status.metadata().clone()),\n                                    event_type: GrpcEventType::ConnectionEnd,\n                                    ..base_event.clone()\n                                },\n                                &UpdateSource::from_window_label(window.label()),\n                            )\n                            .unwrap();\n                    }\n                }\n            }\n        }\n    };\n\n    {\n        let conn_id = conn_id.clone();\n        tauri::async_runtime::spawn(async move {\n            let w = app_handle.clone();\n            tokio::select! {\n                _ = grpc_listen => {\n                    let events = w.db().list_grpc_events(&conn_id).unwrap();\n                    let closed_event = events\n                        .iter()\n                        .find(|e| GrpcEventType::ConnectionEnd == e.event_type);\n                    let closed_status = closed_event.and_then(|e| e.status).unwrap_or(Code::Unavailable as i32);\n                    w.with_tx(|c| {\n                        c.upsert_grpc_connection(\n                            &GrpcConnection{\n                                elapsed: start.elapsed().as_millis() as i32,\n                                status: closed_status,\n                                state: GrpcConnectionState::Closed,\n                                ..c.get_grpc_connection( &conn_id).unwrap().clone()\n                            },\n                            &UpdateSource::from_window_label(window.label()),\n                        )\n                    }).unwrap();\n                },\n                _ = cancelled_rx.changed() => {\n                    w.db().upsert_grpc_event(\n                        &GrpcEvent {\n                            content: \"Cancelled\".to_string(),\n                            event_type: GrpcEventType::ConnectionEnd,\n                            status: Some(Code::Cancelled as i32),\n                            ..base_msg.clone()\n                        },\n                        &UpdateSource::from_window_label(window.label()),\n                    ).unwrap();\n                    w.with_tx(|c| {\n                        c.upsert_grpc_connection(\n                            &GrpcConnection{\n                            elapsed: start.elapsed().as_millis() as i32,\n                            status: Code::Cancelled as i32,\n                            state: GrpcConnectionState::Closed,\n                                ..c.get_grpc_connection( &conn_id).unwrap().clone()\n                            },\n                            &UpdateSource::from_window_label(window.label()),\n                        )\n                    }).unwrap();\n                },\n            }\n            w.unlisten(event_handler);\n        });\n    };\n\n    Ok(conn.id)\n}\n\n#[tauri::command]\nasync fn cmd_restart<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<()> {\n    app_handle.request_restart();\n    Ok(())\n}\n\n#[tauri::command]\nasync fn cmd_send_ephemeral_request<R: Runtime>(\n    mut request: HttpRequest,\n    environment_id: Option<&str>,\n    cookie_jar_id: Option<&str>,\n    window: WebviewWindow,\n    app_handle: AppHandle<R>,\n) -> YaakResult<HttpResponse> {\n    let response = HttpResponse::default();\n    request.id = \"\".to_string();\n    let environment = match environment_id {\n        Some(id) => Some(app_handle.db().get_environment(id)?),\n        None => None,\n    };\n    let cookie_jar = match cookie_jar_id {\n        Some(id) => Some(app_handle.db().get_cookie_jar(id)?),\n        None => None,\n    };\n\n    let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);\n    window.listen_any(format!(\"cancel_http_response_{}\", response.id), move |_event| {\n        if let Err(e) = cancel_tx.send(true) {\n            warn!(\"Failed to send cancel event for ephemeral request {e:?}\");\n        }\n    });\n\n    send_http_request(&window, &request, &response, environment, cookie_jar, &mut cancel_rx).await\n}\n\n#[tauri::command]\nasync fn cmd_format_json(text: &str) -> YaakResult<String> {\n    Ok(format_json(text, \"  \"))\n}\n\n#[tauri::command]\nasync fn cmd_format_graphql(text: &str) -> YaakResult<String> {\n    match pretty_graphql::format_text(text, &Default::default()) {\n        Ok(formatted) => Ok(formatted),\n        Err(_) => Ok(text.to_string()),\n    }\n}\n\n#[tauri::command]\nasync fn cmd_http_response_body<R: Runtime>(\n    window: WebviewWindow<R>,\n    plugin_manager: State<'_, PluginManager>,\n    response: HttpResponse,\n    filter: Option<&str>,\n) -> YaakResult<FilterResponse> {\n    let body_path = match response.body_path {\n        None => {\n            return Ok(FilterResponse { content: String::new(), error: None });\n        }\n        Some(p) => p,\n    };\n\n    let content_type = response\n        .headers\n        .iter()\n        .find_map(|h| {\n            if h.name.eq_ignore_ascii_case(\"content-type\") { Some(h.value.as_str()) } else { None }\n        })\n        .unwrap_or_default();\n\n    let body = read_response_body(&body_path, content_type)\n        .await\n        .ok_or(GenericError(\"Failed to find response body\".to_string()))?;\n\n    match filter {\n        Some(filter) if !filter.is_empty() => Ok(plugin_manager\n            .filter_data(&window.plugin_context(), filter, &body, content_type)\n            .await?),\n        _ => Ok(FilterResponse { content: body, error: None }),\n    }\n}\n\n#[tauri::command]\nasync fn cmd_http_request_body<R: Runtime>(\n    app_handle: AppHandle<R>,\n    response_id: &str,\n) -> YaakResult<Option<Vec<u8>>> {\n    let body_id = format!(\"{}.request\", response_id);\n    let chunks = app_handle.blobs().get_chunks(&body_id)?;\n\n    if chunks.is_empty() {\n        return Ok(None);\n    }\n\n    // Concatenate all chunks\n    let body: Vec<u8> = chunks.into_iter().flat_map(|c| c.data).collect();\n    Ok(Some(body))\n}\n\n#[tauri::command]\nasync fn cmd_get_sse_events(file_path: &str) -> YaakResult<Vec<ServerSentEvent>> {\n    let body = fs::read(file_path)?;\n    let mut event_parser = EventParser::new();\n    event_parser.process_bytes(body.into())?;\n\n    let mut events = Vec::new();\n    while let Some(e) = event_parser.get_event() {\n        if let SSE::Event(e) = e {\n            events.push(ServerSentEvent {\n                event_type: e.event_type,\n                data: e.data,\n                id: e.id,\n                retry: e.retry,\n            });\n        }\n    }\n\n    Ok(events)\n}\n\n#[tauri::command]\nasync fn cmd_get_http_response_events<R: Runtime>(\n    app_handle: AppHandle<R>,\n    response_id: &str,\n) -> YaakResult<Vec<HttpResponseEvent>> {\n    let events: Vec<HttpResponseEvent> = app_handle.db().list_http_response_events(response_id)?;\n    Ok(events)\n}\n\n#[tauri::command]\nasync fn cmd_import_data<R: Runtime>(\n    window: WebviewWindow<R>,\n    file_path: &str,\n) -> YaakResult<BatchUpsertResult> {\n    import_data(&window, file_path).await\n}\n\n#[tauri::command]\nasync fn cmd_http_request_actions<R: Runtime>(\n    window: WebviewWindow<R>,\n    plugin_manager: State<'_, PluginManager>,\n) -> YaakResult<Vec<GetHttpRequestActionsResponse>> {\n    Ok(plugin_manager.get_http_request_actions(&window.plugin_context()).await?)\n}\n\n#[tauri::command]\nasync fn cmd_websocket_request_actions<R: Runtime>(\n    window: WebviewWindow<R>,\n    plugin_manager: State<'_, PluginManager>,\n) -> YaakResult<Vec<GetWebsocketRequestActionsResponse>> {\n    Ok(plugin_manager.get_websocket_request_actions(&window.plugin_context()).await?)\n}\n\n#[tauri::command]\nasync fn cmd_call_websocket_request_action<R: Runtime>(\n    window: WebviewWindow<R>,\n    req: CallWebsocketRequestActionRequest,\n    plugin_manager: State<'_, PluginManager>,\n) -> YaakResult<()> {\n    let websocket_request = window.db().get_websocket_request(&req.args.websocket_request.id)?;\n    Ok(plugin_manager\n        .call_websocket_request_action(\n            &window.plugin_context(),\n            CallWebsocketRequestActionRequest {\n                args: CallWebsocketRequestActionArgs { websocket_request },\n                ..req\n            },\n        )\n        .await?)\n}\n\n#[tauri::command]\nasync fn cmd_workspace_actions<R: Runtime>(\n    window: WebviewWindow<R>,\n    plugin_manager: State<'_, PluginManager>,\n) -> YaakResult<Vec<GetWorkspaceActionsResponse>> {\n    Ok(plugin_manager.get_workspace_actions(&window.plugin_context()).await?)\n}\n\n#[tauri::command]\nasync fn cmd_call_workspace_action<R: Runtime>(\n    window: WebviewWindow<R>,\n    req: CallWorkspaceActionRequest,\n    plugin_manager: State<'_, PluginManager>,\n) -> YaakResult<()> {\n    let workspace = window.db().get_workspace(&req.args.workspace.id)?;\n    Ok(plugin_manager\n        .call_workspace_action(\n            &window.plugin_context(),\n            CallWorkspaceActionRequest { args: CallWorkspaceActionArgs { workspace }, ..req },\n        )\n        .await?)\n}\n\n#[tauri::command]\nasync fn cmd_folder_actions<R: Runtime>(\n    window: WebviewWindow<R>,\n    plugin_manager: State<'_, PluginManager>,\n) -> YaakResult<Vec<GetFolderActionsResponse>> {\n    Ok(plugin_manager.get_folder_actions(&window.plugin_context()).await?)\n}\n\n#[tauri::command]\nasync fn cmd_call_folder_action<R: Runtime>(\n    window: WebviewWindow<R>,\n    req: CallFolderActionRequest,\n    plugin_manager: State<'_, PluginManager>,\n) -> YaakResult<()> {\n    let folder = window.db().get_folder(&req.args.folder.id)?;\n    Ok(plugin_manager\n        .call_folder_action(\n            &window.plugin_context(),\n            CallFolderActionRequest { args: CallFolderActionArgs { folder }, ..req },\n        )\n        .await?)\n}\n\n#[tauri::command]\nasync fn cmd_grpc_request_actions<R: Runtime>(\n    window: WebviewWindow<R>,\n    plugin_manager: State<'_, PluginManager>,\n) -> YaakResult<Vec<GetGrpcRequestActionsResponse>> {\n    Ok(plugin_manager.get_grpc_request_actions(&window.plugin_context()).await?)\n}\n\n#[tauri::command]\nasync fn cmd_template_function_summaries<R: Runtime>(\n    window: WebviewWindow<R>,\n    plugin_manager: State<'_, PluginManager>,\n) -> YaakResult<Vec<GetTemplateFunctionSummaryResponse>> {\n    let results = plugin_manager.get_template_function_summaries(&window.plugin_context()).await?;\n    Ok(results)\n}\n\n#[tauri::command]\nasync fn cmd_template_function_config<R: Runtime>(\n    window: WebviewWindow<R>,\n    plugin_manager: State<'_, PluginManager>,\n    function_name: &str,\n    values: HashMap<String, JsonPrimitive>,\n    model: AnyModel,\n    _environment_id: Option<&str>,\n) -> YaakResult<GetTemplateFunctionConfigResponse> {\n    Ok(plugin_manager\n        .get_template_function_config(&window.plugin_context(), function_name, values, model.id())\n        .await?)\n}\n\n#[tauri::command]\nasync fn cmd_get_http_authentication_summaries<R: Runtime>(\n    window: WebviewWindow<R>,\n    plugin_manager: State<'_, PluginManager>,\n) -> YaakResult<Vec<GetHttpAuthenticationSummaryResponse>> {\n    let results =\n        plugin_manager.get_http_authentication_summaries(&window.plugin_context()).await?;\n    Ok(results.into_iter().map(|(_, a)| a).collect())\n}\n\n#[tauri::command]\nasync fn cmd_get_http_authentication_config<R: Runtime>(\n    window: WebviewWindow<R>,\n    app_handle: AppHandle<R>,\n    plugin_manager: State<'_, PluginManager>,\n    encryption_manager: State<'_, EncryptionManager>,\n    auth_name: &str,\n    values: HashMap<String, JsonPrimitive>,\n    model: AnyModel,\n    environment_id: Option<&str>,\n) -> YaakResult<GetHttpAuthenticationConfigResponse> {\n    // Extract workspace_id and folder_id from the model to resolve the environment chain\n    let (workspace_id, folder_id) = match &model {\n        AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),\n        AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),\n        AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),\n        AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),\n        AnyModel::Workspace(w) => (w.id.clone(), None),\n        _ => return Err(GenericError(\"Unsupported model type for authentication config\".into())),\n    };\n\n    // Resolve environment chain and render the values for token lookup\n    let environment_chain = app_handle.db().resolve_environments(\n        &workspace_id,\n        folder_id.as_deref(),\n        environment_id,\n    )?;\n    let plugin_manager_arc = Arc::new((*plugin_manager).clone());\n    let encryption_manager_arc = Arc::new((*encryption_manager).clone());\n    let cb = PluginTemplateCallback::new(\n        plugin_manager_arc,\n        encryption_manager_arc,\n        &window.plugin_context(),\n        RenderPurpose::Preview,\n    );\n\n    // Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering\n    let values_json: serde_json::Value = serde_json::to_value(&values)?;\n    let rendered_json =\n        render_json_value(values_json, environment_chain, &cb, &RenderOptions::return_empty())\n            .await?;\n\n    // Convert back to HashMap<String, JsonPrimitive>\n    let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;\n\n    Ok(plugin_manager\n        .get_http_authentication_config(\n            &window.plugin_context(),\n            auth_name,\n            rendered_values,\n            model.id(),\n        )\n        .await?)\n}\n\n#[tauri::command]\nasync fn cmd_call_http_request_action<R: Runtime>(\n    window: WebviewWindow<R>,\n    req: CallHttpRequestActionRequest,\n    plugin_manager: State<'_, PluginManager>,\n) -> YaakResult<()> {\n    Ok(plugin_manager\n        .call_http_request_action(\n            &window.plugin_context(),\n            CallHttpRequestActionRequest {\n                args: CallHttpRequestActionArgs {\n                    http_request: resolve_http_request(&window, &req.args.http_request)?.0,\n                    ..req.args\n                },\n                ..req\n            },\n        )\n        .await?)\n}\n\n#[tauri::command]\nasync fn cmd_call_grpc_request_action<R: Runtime>(\n    window: WebviewWindow<R>,\n    req: CallGrpcRequestActionRequest,\n    plugin_manager: State<'_, PluginManager>,\n) -> YaakResult<()> {\n    Ok(plugin_manager\n        .call_grpc_request_action(\n            &window.plugin_context(),\n            CallGrpcRequestActionRequest {\n                args: CallGrpcRequestActionArgs {\n                    grpc_request: resolve_grpc_request(&window, &req.args.grpc_request)?.0,\n                    ..req.args\n                },\n                ..req\n            },\n        )\n        .await?)\n}\n\n#[tauri::command]\nasync fn cmd_call_http_authentication_action<R: Runtime>(\n    window: WebviewWindow<R>,\n    app_handle: AppHandle<R>,\n    plugin_manager: State<'_, PluginManager>,\n    encryption_manager: State<'_, EncryptionManager>,\n    auth_name: &str,\n    action_index: i32,\n    values: HashMap<String, JsonPrimitive>,\n    model: AnyModel,\n    environment_id: Option<&str>,\n) -> YaakResult<()> {\n    // Extract workspace_id and folder_id from the model to resolve the environment chain\n    let (workspace_id, folder_id) = match &model {\n        AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),\n        AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),\n        AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),\n        AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),\n        AnyModel::Workspace(w) => (w.id.clone(), None),\n        _ => return Err(GenericError(\"Unsupported model type for authentication action\".into())),\n    };\n\n    // Resolve environment chain and render the values\n    let environment_chain = app_handle.db().resolve_environments(\n        &workspace_id,\n        folder_id.as_deref(),\n        environment_id,\n    )?;\n    let plugin_manager_arc = Arc::new((*plugin_manager).clone());\n    let encryption_manager_arc = Arc::new((*encryption_manager).clone());\n    let cb = PluginTemplateCallback::new(\n        plugin_manager_arc,\n        encryption_manager_arc,\n        &window.plugin_context(),\n        RenderPurpose::Send,\n    );\n\n    // Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering\n    let values_json: serde_json::Value = serde_json::to_value(&values)?;\n    let rendered_json =\n        render_json_value(values_json, environment_chain, &cb, &RenderOptions::throw()).await?;\n\n    // Convert back to HashMap<String, JsonPrimitive>\n    let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;\n\n    Ok(plugin_manager\n        .call_http_authentication_action(\n            &window.plugin_context(),\n            auth_name,\n            action_index,\n            rendered_values,\n            &model.id(),\n        )\n        .await?)\n}\n\n#[tauri::command]\nasync fn cmd_curl_to_request<R: Runtime>(\n    window: WebviewWindow<R>,\n    command: &str,\n    plugin_manager: State<'_, PluginManager>,\n    workspace_id: &str,\n) -> YaakResult<HttpRequest> {\n    let import_result = plugin_manager.import_data(&window.plugin_context(), command).await?;\n\n    Ok(import_result\n        .resources\n        .http_requests\n        .get(0)\n        .ok_or(GenericError(\"No curl command found\".to_string()))\n        .map(|r| {\n            let mut request = r.clone();\n            request.workspace_id = workspace_id.into();\n            request.id = \"\".to_string();\n            request\n        })?)\n}\n\n#[tauri::command]\nasync fn cmd_export_data<R: Runtime>(\n    app_handle: AppHandle<R>,\n    export_path: &str,\n    workspace_ids: Vec<&str>,\n    include_private_environments: bool,\n) -> YaakResult<()> {\n    let db = app_handle.db();\n    let version = app_handle.package_info().version.to_string();\n    let export_data =\n        get_workspace_export_resources(&db, &version, workspace_ids, include_private_environments)?;\n    let f = File::options()\n        .create(true)\n        .truncate(true)\n        .write(true)\n        .open(export_path)\n        .expect(\"Unable to create file\");\n\n    serde_json::to_writer_pretty(&f, &export_data)\n        .map_err(|e| GenericError(e.to_string()))\n        .expect(\"Failed to write\");\n\n    f.sync_all().expect(\"Failed to sync\");\n\n    Ok(())\n}\n\n#[tauri::command]\nasync fn cmd_save_response<R: Runtime>(\n    app_handle: AppHandle<R>,\n    response_id: &str,\n    filepath: &str,\n) -> YaakResult<()> {\n    let response = app_handle.db().get_http_response(response_id)?;\n\n    let body_path =\n        response.body_path.ok_or(GenericError(\"Response does not have a body\".to_string()))?;\n    fs::copy(body_path, filepath).map_err(|e| GenericError(e.to_string()))?;\n\n    Ok(())\n}\n\n#[tauri::command]\nasync fn cmd_send_http_request<R: Runtime>(\n    app_handle: AppHandle<R>,\n    window: WebviewWindow<R>,\n    environment_id: Option<&str>,\n    cookie_jar_id: Option<&str>,\n    // NOTE: We receive the entire request because to account for the race\n    //   condition where the user may have just edited a field before sending\n    //   that has not yet been saved in the DB.\n    request: HttpRequest,\n) -> YaakResult<HttpResponse> {\n    let blobs = app_handle.blob_manager();\n    let response = app_handle.db().upsert_http_response(\n        &HttpResponse {\n            request_id: request.id.clone(),\n            workspace_id: request.workspace_id.clone(),\n            ..Default::default()\n        },\n        &UpdateSource::from_window_label(window.label()),\n        &blobs,\n    )?;\n\n    let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);\n    app_handle.listen_any(format!(\"cancel_http_response_{}\", response.id), move |_event| {\n        if let Err(e) = cancel_tx.send(true) {\n            warn!(\"Failed to send cancel event for request {e:?}\");\n        }\n    });\n\n    let environment = match environment_id {\n        Some(id) => match app_handle.db().get_environment(id) {\n            Ok(env) => Some(env),\n            Err(e) => {\n                warn!(\"Failed to find environment by id {id} {}\", e);\n                None\n            }\n        },\n        None => None,\n    };\n\n    let cookie_jar = match cookie_jar_id {\n        Some(id) => Some(app_handle.db().get_cookie_jar(id)?),\n        None => None,\n    };\n\n    let r = match send_http_request(\n        &window,\n        &request,\n        &response,\n        environment,\n        cookie_jar,\n        &mut cancel_rx,\n    )\n    .await\n    {\n        Ok(r) => r,\n        Err(e) => {\n            let resp = app_handle.db().get_http_response(&response.id)?;\n            app_handle.db().upsert_http_response(\n                &HttpResponse {\n                    state: HttpResponseState::Closed,\n                    error: Some(e.to_string()),\n                    ..resp\n                },\n                &UpdateSource::from_window_label(window.label()),\n                &blobs,\n            )?\n        }\n    };\n\n    Ok(r)\n}\n\n#[tauri::command]\nasync fn cmd_reload_plugins<R: Runtime>(\n    app_handle: AppHandle<R>,\n    window: WebviewWindow<R>,\n    plugin_manager: State<'_, PluginManager>,\n) -> YaakResult<Vec<(String, String)>> {\n    let plugins = app_handle.db().list_plugins()?;\n    let plugin_context =\n        PluginContext::new(Some(window.label().to_string()), window.workspace_id());\n    let errors = plugin_manager.initialize_all_plugins(plugins, &plugin_context).await;\n    Ok(errors)\n}\n\n#[tauri::command]\nasync fn cmd_plugin_info<R: Runtime>(\n    id: &str,\n    app_handle: AppHandle<R>,\n    plugin_manager: State<'_, PluginManager>,\n) -> YaakResult<PluginMetadata> {\n    let plugin = app_handle.db().get_plugin(id)?;\n    Ok(plugin_manager\n        .get_plugin_by_dir(plugin.directory.as_str())\n        .await\n        .ok_or(GenericError(\"Failed to find plugin for info\".to_string()))?\n        .info())\n}\n\n#[tauri::command]\nasync fn cmd_delete_all_grpc_connections<R: Runtime>(\n    request_id: &str,\n    app_handle: AppHandle<R>,\n    window: WebviewWindow<R>,\n) -> YaakResult<()> {\n    Ok(app_handle.db().delete_all_grpc_connections_for_request(\n        request_id,\n        &UpdateSource::from_window_label(window.label()),\n    )?)\n}\n\n#[tauri::command]\nasync fn cmd_delete_send_history<R: Runtime>(\n    workspace_id: &str,\n    app_handle: AppHandle<R>,\n    window: WebviewWindow<R>,\n) -> YaakResult<()> {\n    Ok(app_handle.with_tx(|tx| {\n        let source = &UpdateSource::from_window_label(window.label());\n        tx.delete_all_http_responses_for_workspace(workspace_id, source)?;\n        tx.delete_all_grpc_connections_for_workspace(workspace_id, source)?;\n        tx.delete_all_websocket_connections_for_workspace(workspace_id, source)?;\n        Ok(())\n    })?)\n}\n\n#[tauri::command]\nasync fn cmd_delete_all_http_responses<R: Runtime>(\n    request_id: &str,\n    app_handle: AppHandle<R>,\n    window: WebviewWindow<R>,\n) -> YaakResult<()> {\n    Ok(app_handle.db().delete_all_http_responses_for_request(\n        request_id,\n        &UpdateSource::from_window_label(window.label()),\n    )?)\n}\n\n#[tauri::command]\nasync fn cmd_get_workspace_meta<R: Runtime>(\n    app_handle: AppHandle<R>,\n    workspace_id: &str,\n) -> YaakResult<WorkspaceMeta> {\n    let db = app_handle.db();\n    let workspace = db.get_workspace(workspace_id)?;\n    Ok(db.get_or_create_workspace_meta(&workspace.id)?)\n}\n\n#[tauri::command]\nasync fn cmd_new_child_window(\n    parent_window: WebviewWindow,\n    url: &str,\n    label: &str,\n    title: &str,\n    inner_size: (f64, f64),\n) -> YaakResult<()> {\n    window::create_child_window(&parent_window, url, label, title, inner_size)?;\n    Ok(())\n}\n\n#[tauri::command]\nasync fn cmd_new_main_window(app_handle: AppHandle, url: &str) -> YaakResult<()> {\n    window::create_main_window(&app_handle, url)?;\n    Ok(())\n}\n\n#[tauri::command]\nasync fn cmd_check_for_updates<R: Runtime>(\n    window: WebviewWindow<R>,\n    yaak_updater: State<'_, Mutex<YaakUpdater>>,\n) -> YaakResult<bool> {\n    let update_mode = get_update_mode(&window).await?;\n    let settings = window.db().get_settings();\n    Ok(yaak_updater\n        .lock()\n        .await\n        .check_now(&window, update_mode, settings.auto_download_updates, UpdateTrigger::User)\n        .await?)\n}\n\n#[cfg_attr(mobile, tauri::mobile_entry_point)]\npub fn run() {\n    let mut builder = tauri::Builder::default().plugin(\n        Builder::default()\n            .targets([\n                Target::new(TargetKind::Stdout),\n                Target::new(TargetKind::LogDir { file_name: None }),\n                Target::new(TargetKind::Webview),\n            ])\n            .level_for(\"plugin_runtime\", log::LevelFilter::Info)\n            .level_for(\"cookie_store\", log::LevelFilter::Info)\n            .level_for(\"eventsource_client::event_parser\", log::LevelFilter::Info)\n            .level_for(\"h2\", log::LevelFilter::Info)\n            .level_for(\"hyper\", log::LevelFilter::Info)\n            .level_for(\"hyper_util\", log::LevelFilter::Info)\n            .level_for(\"hyper_rustls\", log::LevelFilter::Info)\n            .level_for(\"reqwest\", log::LevelFilter::Info)\n            .level_for(\"sqlx\", log::LevelFilter::Debug)\n            .level_for(\"tao\", log::LevelFilter::Info)\n            .level_for(\"tokio_util\", log::LevelFilter::Info)\n            .level_for(\"tonic\", log::LevelFilter::Info)\n            .level_for(\"tower\", log::LevelFilter::Info)\n            .level_for(\"tracing\", log::LevelFilter::Warn)\n            .level_for(\"swc_ecma_codegen\", log::LevelFilter::Off)\n            .level_for(\"swc_ecma_transforms_base\", log::LevelFilter::Off)\n            .with_colors(ColoredLevelConfig::default())\n            .level(if is_dev() { log::LevelFilter::Debug } else { log::LevelFilter::Info })\n            .build(),\n    );\n\n    // Only enable single-instance in production builds. In dev mode, we want to allow\n    // multiple instances for testing and worktree workflows (running multiple branches).\n    if !is_dev() {\n        builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {\n            // When trying to open a new app instance (common operation on Linux),\n            // focus the first existing window we find instead of opening a new one\n            // TODO: Keep track of the last focused window and always focus that one\n            if let Some(window) = app.webview_windows().values().next() {\n                let _ = window.set_focus();\n            }\n        }));\n    }\n\n    builder = builder\n        .plugin(tauri_plugin_clipboard_manager::init())\n        .plugin(tauri_plugin_opener::init())\n        // Don't restore StateFlags::DECORATIONS because we want to be able to toggle them on/off on a restart\n        // We could* make this work if we toggled them in the frontend before the window closes, but, this is nicer.\n        .plugin(\n            tauri_plugin_window_state::Builder::new()\n                .with_state_flags(StateFlags::all() - StateFlags::DECORATIONS)\n                .build(),\n        )\n        .plugin(tauri_plugin_deep_link::init())\n        .plugin(tauri_plugin_shell::init())\n        .plugin(tauri_plugin_dialog::init())\n        .plugin(tauri_plugin_os::init())\n        .plugin(tauri_plugin_fs::init())\n        .plugin(yaak_mac_window::init())\n        .plugin(models_ext::init()) // Database setup only. Must be before plugins_ext which depends on db\n        .plugin(plugins_ext::init())\n        .plugin(yaak_fonts::init());\n\n    #[cfg(feature = \"license\")]\n    {\n        builder = builder.plugin(yaak_license::init());\n    }\n\n    #[cfg(feature = \"updater\")]\n    {\n        builder = builder.plugin(tauri_plugin_updater::Builder::default().build());\n    }\n\n    builder\n        .setup(|app| {\n            // Initialize HTTP connection manager\n            app.manage(yaak_http::manager::HttpConnectionManager::new());\n\n            // Initialize encryption manager\n            let query_manager =\n                app.state::<yaak_models::query_manager::QueryManager>().inner().clone();\n            let app_id = app.config().identifier.to_string();\n            app.manage(yaak_crypto::manager::EncryptionManager::new(query_manager, app_id));\n\n            {\n                let app_handle = app.app_handle().clone();\n                app.deep_link().on_open_url(move |event| {\n                    info!(\"Handling deep link open\");\n                    let app_handle = app_handle.clone();\n                    tauri::async_runtime::spawn(async move {\n                        for url in event.urls() {\n                            if let Err(e) = handle_deep_link(&app_handle, &url).await {\n                                warn!(\"Failed to handle deep link {}: {e:?}\", url.to_string());\n                                let _ = app_handle.emit(\n                                    \"show_toast\",\n                                    ShowToastRequest {\n                                        message: format!(\n                                            \"Error handling deep link: {}\",\n                                            e.to_string()\n                                        ),\n                                        color: Some(Color::Danger),\n                                        icon: None,\n                                        timeout: None,\n                                    },\n                                );\n                            };\n                        }\n                    });\n                });\n            };\n\n            // Add updater\n            let yaak_updater = YaakUpdater::new();\n            app.manage(Mutex::new(yaak_updater));\n\n            // Add notifier\n            let yaak_notifier = YaakNotifier::new();\n            app.manage(Mutex::new(yaak_notifier));\n\n            // Add GRPC manager\n            let protoc_include_dir = app\n                .path()\n                .resolve(\"vendored/protoc/include\", BaseDirectory::Resource)\n                .expect(\"failed to resolve protoc include directory\");\n            let protoc_bin_name = if cfg!(windows) { \"yaakprotoc.exe\" } else { \"yaakprotoc\" };\n            let protoc_bin_path = app\n                .path()\n                .resolve(format!(\"vendored/protoc/{}\", protoc_bin_name), BaseDirectory::Resource)\n                .expect(\"failed to resolve yaakprotoc binary\");\n            let grpc_config = GrpcConfig { protoc_include_dir, protoc_bin_path };\n            let grpc_handle = GrpcHandle::new(grpc_config);\n            app.manage(Mutex::new(grpc_handle));\n\n            // Add WebSocket manager\n            let ws_manager = yaak_ws::WebsocketManager::new();\n            app.manage(Mutex::new(ws_manager));\n\n            // Specific settings\n            let settings = app.db().get_settings();\n            app.app_handle().set_native_titlebar(settings.use_native_titlebar);\n\n            monitor_plugin_events(&app.app_handle().clone());\n\n            Ok(())\n        })\n        .invoke_handler(tauri::generate_handler![\n            cmd_call_http_authentication_action,\n            cmd_call_http_request_action,\n            cmd_call_websocket_request_action,\n            cmd_call_workspace_action,\n            cmd_call_folder_action,\n            cmd_call_grpc_request_action,\n            cmd_check_for_updates,\n            cmd_curl_to_request,\n            cmd_delete_all_grpc_connections,\n            cmd_delete_all_http_responses,\n            cmd_delete_send_history,\n            cmd_dismiss_notification,\n            cmd_export_data,\n            cmd_http_request_body,\n            cmd_http_response_body,\n            cmd_format_json,\n            cmd_format_graphql,\n            cmd_get_http_authentication_summaries,\n            cmd_get_http_authentication_config,\n            cmd_get_sse_events,\n            cmd_get_http_response_events,\n            cmd_get_workspace_meta,\n            cmd_grpc_go,\n            cmd_grpc_reflect,\n            cmd_grpc_request_actions,\n            cmd_http_request_actions,\n            cmd_websocket_request_actions,\n            cmd_workspace_actions,\n            cmd_folder_actions,\n            cmd_import_data,\n            cmd_metadata,\n            cmd_new_child_window,\n            cmd_new_main_window,\n            cmd_plugin_info,\n            cmd_reload_plugins,\n            cmd_render_template,\n            cmd_restart,\n            cmd_save_response,\n            cmd_send_ephemeral_request,\n            cmd_send_http_request,\n            cmd_template_function_config,\n            cmd_template_function_summaries,\n            cmd_template_tokens_to_string,\n            //\n            //\n            // Migrated commands\n            crate::commands::cmd_decrypt_template,\n            crate::commands::cmd_default_headers,\n            crate::commands::cmd_disable_encryption,\n            crate::commands::cmd_enable_encryption,\n            crate::commands::cmd_get_themes,\n            crate::commands::cmd_reveal_workspace_key,\n            crate::commands::cmd_secure_template,\n            crate::commands::cmd_set_workspace_key,\n            //\n            // Models commands\n            models_ext::models_delete,\n            models_ext::models_duplicate,\n            models_ext::models_get_graphql_introspection,\n            models_ext::models_get_settings,\n            models_ext::models_grpc_events,\n            models_ext::models_upsert,\n            models_ext::models_upsert_graphql_introspection,\n            models_ext::models_websocket_events,\n            models_ext::models_workspace_models,\n            //\n            // Sync commands\n            sync_ext::cmd_sync_calculate,\n            sync_ext::cmd_sync_calculate_fs,\n            sync_ext::cmd_sync_apply,\n            sync_ext::cmd_sync_watch,\n            //\n            // Git commands\n            git_ext::cmd_git_checkout,\n            git_ext::cmd_git_branch,\n            git_ext::cmd_git_delete_branch,\n            git_ext::cmd_git_delete_remote_branch,\n            git_ext::cmd_git_merge_branch,\n            git_ext::cmd_git_rename_branch,\n            git_ext::cmd_git_status,\n            git_ext::cmd_git_log,\n            git_ext::cmd_git_initialize,\n            git_ext::cmd_git_clone,\n            git_ext::cmd_git_commit,\n            git_ext::cmd_git_fetch_all,\n            git_ext::cmd_git_push,\n            git_ext::cmd_git_pull,\n            git_ext::cmd_git_pull_force_reset,\n            git_ext::cmd_git_pull_merge,\n            git_ext::cmd_git_add,\n            git_ext::cmd_git_unstage,\n            git_ext::cmd_git_reset_changes,\n            git_ext::cmd_git_add_credential,\n            git_ext::cmd_git_remotes,\n            git_ext::cmd_git_add_remote,\n            git_ext::cmd_git_rm_remote,\n            //\n            // Plugin commands\n            plugins_ext::cmd_plugin_init_errors,\n            plugins_ext::cmd_plugins_install_from_directory,\n            plugins_ext::cmd_plugins_search,\n            plugins_ext::cmd_plugins_install,\n            plugins_ext::cmd_plugins_uninstall,\n            plugins_ext::cmd_plugins_updates,\n            plugins_ext::cmd_plugins_update_all,\n            //\n            // WebSocket commands\n            ws_ext::cmd_ws_delete_connections,\n            ws_ext::cmd_ws_send,\n            ws_ext::cmd_ws_close,\n            ws_ext::cmd_ws_connect,\n        ])\n        .build(tauri::generate_context!())\n        .expect(\"error while running tauri application\")\n        .run(|app_handle, event| {\n            match event {\n                RunEvent::Ready => {\n                    let _ = window::create_main_window(app_handle, \"/\");\n                    let h = app_handle.clone();\n                    tauri::async_runtime::spawn(async move {\n                        let info = history::get_or_upsert_launch_info(&h);\n                        debug!(\"Launched Yaak {:?}\", info);\n                    });\n\n                    // Cancel pending requests\n                    let h = app_handle.clone();\n                    tauri::async_runtime::block_on(async move {\n                        let db = h.db();\n                        let _ = db.cancel_pending_http_responses();\n                        let _ = db.cancel_pending_grpc_connections();\n                        let _ = db.cancel_pending_websocket_connections();\n                    });\n                }\n                RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {\n                    if cfg!(feature = \"updater\") {\n                        // Run update check whenever the window is focused\n                        let w = app_handle.get_webview_window(&label).unwrap();\n                        let h = app_handle.clone();\n                        tauri::async_runtime::spawn(async move {\n                            let settings = w.db().get_settings();\n                            if settings.autoupdate {\n                                time::sleep(Duration::from_secs(3)).await; // Wait a bit so it's not so jarring\n                                let val: State<'_, Mutex<YaakUpdater>> = h.state();\n                                let update_mode = get_update_mode(&w).await.unwrap();\n                                if let Err(e) = val\n                                    .lock()\n                                    .await\n                                    .maybe_check(&w, settings.auto_download_updates, update_mode)\n                                    .await\n                                {\n                                    warn!(\"Failed to check for updates {e:?}\");\n                                }\n                            };\n                        });\n                    }\n\n                    let h = app_handle.clone();\n                    tauri::async_runtime::spawn(async move {\n                        let windows = h.webview_windows();\n                        let w = windows.values().next().unwrap();\n                        tokio::time::sleep(Duration::from_millis(4000)).await;\n                        let val: State<'_, Mutex<YaakNotifier>> = w.state();\n                        let mut n = val.lock().await;\n                        if let Err(e) = n.maybe_check(&w).await {\n                            warn!(\"Failed to check for notifications {}\", e)\n                        }\n                    });\n                }\n                RunEvent::WindowEvent { event: WindowEvent::CloseRequested { .. }, .. } => {\n                    if let Err(e) = app_handle.save_window_state(StateFlags::all()) {\n                        warn!(\"Failed to save window state {e:?}\");\n                    } else {\n                        info!(\"Saved window state\");\n                    };\n                }\n                _ => {}\n            };\n        });\n}\n\nasync fn get_update_mode<R: Runtime>(window: &WebviewWindow<R>) -> YaakResult<UpdateMode> {\n    let settings = window.db().get_settings();\n    Ok(UpdateMode::new(settings.update_channel.as_str()))\n}\n\nfn safe_uri(endpoint: &str) -> String {\n    if endpoint.starts_with(\"http://\") || endpoint.starts_with(\"https://\") {\n        endpoint.into()\n    } else {\n        format!(\"http://{}\", endpoint)\n    }\n}\n\nfn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {\n    let app_handle = app_handle.clone();\n    tauri::async_runtime::spawn(async move {\n        let plugin_manager: State<'_, PluginManager> = app_handle.state();\n        let (rx_id, mut rx) = plugin_manager.subscribe(\"app\").await;\n\n        while let Some(event) = rx.recv().await {\n            let app_handle = app_handle.clone();\n            let plugin =\n                match plugin_manager.get_plugin_by_ref_id(event.plugin_ref_id.as_str()).await {\n                    None => {\n                        warn!(\"Failed to get plugin for event {:?}\", event);\n                        continue;\n                    }\n                    Some(p) => p,\n                };\n\n            // We might have recursive back-and-forth calls between app and plugin, so we don't\n            // want to block here\n            tauri::async_runtime::spawn(async move {\n                let ev = plugin_events::handle_plugin_event(&app_handle, &event, &plugin).await;\n\n                let ev = match ev {\n                    Ok(Some(ev)) => ev,\n                    Ok(None) => return,\n                    Err(e) => {\n                        warn!(\"Failed to handle plugin event: {e:?}\");\n                        let _ = app_handle.emit(\n                            \"show_toast\",\n                            InternalEventPayload::ShowToastRequest(ShowToastRequest {\n                                message: e.to_string(),\n                                color: Some(Color::Danger),\n                                icon: None,\n                                timeout: Some(30000),\n                            }),\n                        );\n                        return;\n                    }\n                };\n\n                let plugin_manager: State<'_, PluginManager> = app_handle.state();\n                if let Err(e) = plugin_manager.reply(&event, &ev).await {\n                    warn!(\"Failed to reply to plugin manager: {:?}\", e)\n                }\n            });\n        }\n        plugin_manager.unsubscribe(rx_id.as_str()).await;\n    });\n}\n\nasync fn call_frontend<R: Runtime>(\n    window: &WebviewWindow<R>,\n    event: &InternalEvent,\n) -> Option<InternalEventPayload> {\n    window.emit_to(window.label(), \"plugin_event\", event.clone()).unwrap();\n    let (tx, mut rx) = tokio::sync::watch::channel(None);\n\n    let reply_id = event.id.clone();\n    let event_id = window.clone().listen(reply_id, move |ev| {\n        let resp: InternalEvent = serde_json::from_str(ev.payload()).unwrap();\n        if let Err(e) = tx.send(Some(resp.payload)) {\n            warn!(\"Failed to prompt for text {e:?}\");\n        }\n    });\n\n    // When reply shows up, unlisten to events and return\n    if let Err(e) = rx.changed().await {\n        warn!(\"Failed to check channel changed {e:?}\");\n    }\n    window.unlisten(event_id);\n\n    let v = rx.borrow();\n    v.to_owned()\n}\n\nfn get_window_from_plugin_context<R: Runtime>(\n    app_handle: &AppHandle<R>,\n    plugin_context: &PluginContext,\n) -> Result<WebviewWindow<R>> {\n    let label = match &plugin_context.label {\n        Some(label) => label,\n        None => {\n            return app_handle\n                .webview_windows()\n                .iter()\n                .next()\n                .map(|(_, w)| w.to_owned())\n                .ok_or(GenericError(\"No windows open\".to_string()));\n        }\n    };\n\n    let window = app_handle\n        .webview_windows()\n        .iter()\n        .find_map(|(_, w)| if w.label() == label { Some(w.to_owned()) } else { None });\n\n    if window.is_none() {\n        error!(\"Failed to find window by {plugin_context:?}\");\n    }\n\n    Ok(window.ok_or(GenericError(format!(\"Failed to find window for {}\", label)))?)\n}\n\nfn workspace_from_window<R: Runtime>(window: &WebviewWindow<R>) -> Option<Workspace> {\n    window.workspace_id().and_then(|id| window.db().get_workspace(&id).ok())\n}\n\nfn environment_from_window<R: Runtime>(window: &WebviewWindow<R>) -> Option<Environment> {\n    window.environment_id().and_then(|id| window.db().get_environment(&id).ok())\n}\n\nfn cookie_jar_from_window<R: Runtime>(window: &WebviewWindow<R>) -> Option<CookieJar> {\n    window.cookie_jar_id().and_then(|id| window.db().get_cookie_jar(&id).ok())\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/main.rs",
    "content": "#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nfn main() {\n    tauri_app_lib::run();\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/models_ext.rs",
    "content": "//! Tauri-specific extensions for yaak-models.\n//!\n//! This module provides the Tauri plugin initialization and extension traits\n//! that allow accessing QueryManager and BlobManager from Tauri's Manager types.\n\nuse chrono::Utc;\nuse log::error;\nuse std::time::Duration;\nuse tauri::plugin::TauriPlugin;\nuse tauri::{Emitter, Manager, Runtime, State};\nuse tauri_plugin_dialog::{DialogExt, MessageDialogKind};\nuse yaak_models::blob_manager::BlobManager;\nuse yaak_models::db_context::DbContext;\nuse yaak_models::error::Result;\nuse yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};\nuse yaak_models::query_manager::QueryManager;\nuse yaak_models::util::UpdateSource;\nuse yaak_plugins::manager::PluginManager;\n\nconst MODEL_CHANGES_RETENTION_HOURS: i64 = 1;\nconst MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;\nconst MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;\n\nstruct ModelChangeCursor {\n    created_at: String,\n    id: i64,\n}\n\nimpl ModelChangeCursor {\n    fn from_launch_time() -> Self {\n        Self {\n            created_at: Utc::now().naive_utc().format(\"%Y-%m-%d %H:%M:%S%.3f\").to_string(),\n            id: 0,\n        }\n    }\n}\n\nfn drain_model_changes_batch<R: Runtime>(\n    query_manager: &QueryManager,\n    app_handle: &tauri::AppHandle<R>,\n    cursor: &mut ModelChangeCursor,\n) -> bool {\n    let changes = match query_manager.connect().list_model_changes_since(\n        &cursor.created_at,\n        cursor.id,\n        MODEL_CHANGES_POLL_BATCH_SIZE,\n    ) {\n        Ok(changes) => changes,\n        Err(err) => {\n            error!(\"Failed to poll model_changes rows: {err:?}\");\n            return false;\n        }\n    };\n\n    if changes.is_empty() {\n        return false;\n    }\n\n    let fetched_count = changes.len();\n    for change in changes {\n        cursor.created_at = change.created_at;\n        cursor.id = change.id;\n\n        // Local window-originated writes are forwarded immediately from the\n        // in-memory model event channel.\n        if matches!(change.payload.update_source, UpdateSource::Window { .. }) {\n            continue;\n        }\n        if let Err(err) = app_handle.emit(\"model_write\", change.payload) {\n            error!(\"Failed to emit model_write event: {err:?}\");\n        }\n    }\n\n    fetched_count == MODEL_CHANGES_POLL_BATCH_SIZE\n}\n\nasync fn run_model_change_poller<R: Runtime>(\n    query_manager: QueryManager,\n    app_handle: tauri::AppHandle<R>,\n    mut cursor: ModelChangeCursor,\n) {\n    loop {\n        while drain_model_changes_batch(&query_manager, &app_handle, &mut cursor) {}\n        tokio::time::sleep(Duration::from_millis(MODEL_CHANGES_POLL_INTERVAL_MS)).await;\n    }\n}\n\n/// Extension trait for accessing the QueryManager from Tauri Manager types.\npub trait QueryManagerExt<'a, R> {\n    fn db_manager(&'a self) -> State<'a, QueryManager>;\n    fn db(&'a self) -> DbContext<'a>;\n    fn with_tx<F, T>(&'a self, func: F) -> Result<T>\n    where\n        F: FnOnce(&DbContext) -> Result<T>;\n}\n\nimpl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {\n    fn db_manager(&'a self) -> State<'a, QueryManager> {\n        self.state::<QueryManager>()\n    }\n\n    fn db(&'a self) -> DbContext<'a> {\n        let qm = self.state::<QueryManager>();\n        qm.inner().connect()\n    }\n\n    fn with_tx<F, T>(&'a self, func: F) -> Result<T>\n    where\n        F: FnOnce(&DbContext) -> Result<T>,\n    {\n        let qm = self.state::<QueryManager>();\n        qm.inner().with_tx(func)\n    }\n}\n\n/// Extension trait for accessing the BlobManager from Tauri Manager types.\npub trait BlobManagerExt<'a, R> {\n    fn blob_manager(&'a self) -> State<'a, BlobManager>;\n    fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext;\n}\n\nimpl<'a, R: Runtime, M: Manager<R>> BlobManagerExt<'a, R> for M {\n    fn blob_manager(&'a self) -> State<'a, BlobManager> {\n        self.state::<BlobManager>()\n    }\n\n    fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext {\n        let manager = self.state::<BlobManager>();\n        manager.inner().connect()\n    }\n}\n\n// Commands for yaak-models\nuse tauri::WebviewWindow;\n\n#[tauri::command]\npub(crate) fn models_upsert<R: Runtime>(\n    window: WebviewWindow<R>,\n    model: AnyModel,\n) -> Result<String> {\n    use yaak_models::error::Error::GenericError;\n\n    let db = window.db();\n    let blobs = window.blob_manager();\n    let source = &UpdateSource::from_window_label(window.label());\n    let id = match model {\n        AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,\n        AnyModel::Environment(m) => db.upsert_environment(&m, source)?.id,\n        AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,\n        AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,\n        AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,\n        AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, &blobs)?.id,\n        AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id,\n        AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,\n        AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,\n        AnyModel::WebsocketRequest(m) => db.upsert_websocket_request(&m, source)?.id,\n        AnyModel::Workspace(m) => db.upsert_workspace(&m, source)?.id,\n        AnyModel::WorkspaceMeta(m) => db.upsert_workspace_meta(&m, source)?.id,\n        a => return Err(GenericError(format!(\"Cannot upsert AnyModel {a:?})\"))),\n    };\n\n    Ok(id)\n}\n\n#[tauri::command]\npub(crate) fn models_delete<R: Runtime>(\n    window: WebviewWindow<R>,\n    model: AnyModel,\n) -> Result<String> {\n    use yaak_models::error::Error::GenericError;\n\n    let blobs = window.blob_manager();\n    // Use transaction for deletions because it might recurse\n    window.with_tx(|tx| {\n        let source = &UpdateSource::from_window_label(window.label());\n        let id = match model {\n            AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,\n            AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,\n            AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,\n            AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,\n            AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,\n            AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,\n            AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id,\n            AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,\n            AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,\n            AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,\n            AnyModel::Workspace(m) => tx.delete_workspace(&m, source)?.id,\n            a => return Err(GenericError(format!(\"Cannot delete AnyModel {a:?})\"))),\n        };\n        Ok(id)\n    })\n}\n\n#[tauri::command]\npub(crate) fn models_duplicate<R: Runtime>(\n    window: WebviewWindow<R>,\n    model: AnyModel,\n) -> Result<String> {\n    use yaak_models::error::Error::GenericError;\n\n    // Use transaction for duplications because it might recurse\n    window.with_tx(|tx| {\n        let source = &UpdateSource::from_window_label(window.label());\n        let id = match model {\n            AnyModel::Environment(m) => tx.duplicate_environment(&m, source)?.id,\n            AnyModel::Folder(m) => tx.duplicate_folder(&m, source)?.id,\n            AnyModel::GrpcRequest(m) => tx.duplicate_grpc_request(&m, source)?.id,\n            AnyModel::HttpRequest(m) => tx.duplicate_http_request(&m, source)?.id,\n            AnyModel::WebsocketRequest(m) => tx.duplicate_websocket_request(&m, source)?.id,\n            a => return Err(GenericError(format!(\"Cannot duplicate AnyModel {a:?})\"))),\n        };\n\n        Ok(id)\n    })\n}\n\n#[tauri::command]\npub(crate) fn models_websocket_events<R: Runtime>(\n    app_handle: tauri::AppHandle<R>,\n    connection_id: &str,\n) -> Result<Vec<WebsocketEvent>> {\n    Ok(app_handle.db().list_websocket_events(connection_id)?)\n}\n\n#[tauri::command]\npub(crate) fn models_grpc_events<R: Runtime>(\n    app_handle: tauri::AppHandle<R>,\n    connection_id: &str,\n) -> Result<Vec<GrpcEvent>> {\n    Ok(app_handle.db().list_grpc_events(connection_id)?)\n}\n\n#[tauri::command]\npub(crate) fn models_get_settings<R: Runtime>(app_handle: tauri::AppHandle<R>) -> Result<Settings> {\n    Ok(app_handle.db().get_settings())\n}\n\n#[tauri::command]\npub(crate) fn models_get_graphql_introspection<R: Runtime>(\n    app_handle: tauri::AppHandle<R>,\n    request_id: &str,\n) -> Result<Option<GraphQlIntrospection>> {\n    Ok(app_handle.db().get_graphql_introspection(request_id))\n}\n\n#[tauri::command]\npub(crate) fn models_upsert_graphql_introspection<R: Runtime>(\n    app_handle: tauri::AppHandle<R>,\n    request_id: &str,\n    workspace_id: &str,\n    content: Option<String>,\n    window: WebviewWindow<R>,\n) -> Result<GraphQlIntrospection> {\n    let source = UpdateSource::from_window_label(window.label());\n    Ok(app_handle.db().upsert_graphql_introspection(workspace_id, request_id, content, &source)?)\n}\n\n#[tauri::command]\npub(crate) async fn models_workspace_models<R: Runtime>(\n    window: WebviewWindow<R>,\n    workspace_id: Option<&str>,\n    plugin_manager: State<'_, PluginManager>,\n) -> Result<String> {\n    let mut l: Vec<AnyModel> = Vec::new();\n\n    // Add the global models\n    {\n        let db = window.db();\n        l.push(db.get_settings().into());\n        l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());\n        l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());\n    }\n\n    let plugins = {\n        let db = window.db();\n        db.list_plugins()?\n    };\n\n    let plugins = plugin_manager.resolve_plugins_for_runtime_from_db(plugins).await;\n    l.append(&mut plugins.into_iter().map(Into::into).collect());\n\n    // Add the workspace children\n    if let Some(wid) = workspace_id {\n        let db = window.db();\n        l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());\n        l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());\n        l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());\n        l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());\n        l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());\n        l.append(&mut db.list_http_requests(wid)?.into_iter().map(Into::into).collect());\n        l.append(&mut db.list_http_responses(wid, None)?.into_iter().map(Into::into).collect());\n        l.append(&mut db.list_websocket_connections(wid)?.into_iter().map(Into::into).collect());\n        l.append(&mut db.list_websocket_requests(wid)?.into_iter().map(Into::into).collect());\n        l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect());\n    }\n\n    let j = serde_json::to_string(&l)?;\n\n    Ok(escape_str_for_webview(&j))\n}\n\nfn escape_str_for_webview(input: &str) -> String {\n    input\n        .chars()\n        .map(|c| {\n            let code = c as u32;\n            // ASCII\n            if code <= 0x7F {\n                c.to_string()\n                // BMP characters encoded normally\n            } else if code < 0xFFFF {\n                format!(\"\\\\u{:04X}\", code)\n                // Beyond BMP encoded a surrogate pairs\n            } else {\n                let high = ((code - 0x10000) >> 10) + 0xD800;\n                let low = ((code - 0x10000) & 0x3FF) + 0xDC00;\n                format!(\"\\\\u{:04X}\\\\u{:04X}\", high, low)\n            }\n        })\n        .collect()\n}\n\n/// Initialize database managers as a plugin (for initialization order).\n/// Commands are in the main invoke_handler.\n/// This must be registered before other plugins that depend on the database.\npub fn init<R: Runtime>() -> TauriPlugin<R> {\n    tauri::plugin::Builder::new(\"yaak-models-db\")\n        .setup(|app_handle, _api| {\n            let app_path = app_handle.path().app_data_dir().unwrap();\n            let db_path = app_path.join(\"db.sqlite\");\n            let blob_path = app_path.join(\"blobs.sqlite\");\n\n            let (query_manager, blob_manager, rx) =\n                match yaak_models::init_standalone(&db_path, &blob_path) {\n                    Ok(result) => result,\n                    Err(e) => {\n                        app_handle\n                            .dialog()\n                            .message(e.to_string())\n                            .kind(MessageDialogKind::Error)\n                            .blocking_show();\n                        return Err(Box::from(e.to_string()));\n                    }\n                };\n\n            let db = query_manager.connect();\n            if let Err(err) = db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)\n            {\n                error!(\"Failed to prune model_changes rows on startup: {err:?}\");\n            }\n            // Only stream writes that happen after this app launch.\n            let cursor = ModelChangeCursor::from_launch_time();\n\n            let poll_query_manager = query_manager.clone();\n\n            app_handle.manage(query_manager);\n            app_handle.manage(blob_manager);\n\n            // Poll model_changes so all writers (including external CLI processes) update the UI.\n            let app_handle_poll = app_handle.clone();\n            let query_manager = poll_query_manager;\n            tauri::async_runtime::spawn(async move {\n                run_model_change_poller(query_manager, app_handle_poll, cursor).await;\n            });\n\n            // Fast path for local app writes initiated by frontend windows. This keeps the\n            // current sync-model UX snappy, while DB polling handles external writers (CLI).\n            let app_handle_local = app_handle.clone();\n            tauri::async_runtime::spawn(async move {\n                for payload in rx {\n                    if !matches!(payload.update_source, UpdateSource::Window { .. }) {\n                        continue;\n                    }\n                    if let Err(err) = app_handle_local.emit(\"model_write\", payload) {\n                        error!(\"Failed to emit local model_write event: {err:?}\");\n                    }\n                }\n            });\n\n            Ok(())\n        })\n        .build()\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/notifications.rs",
    "content": "use crate::error::Result;\nuse crate::history::get_or_upsert_launch_info;\nuse crate::models_ext::QueryManagerExt;\nuse chrono::{DateTime, Utc};\nuse log::{debug, info};\nuse reqwest::Method;\nuse serde::{Deserialize, Serialize};\nuse std::time::Instant;\nuse tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};\nuse ts_rs::TS;\nuse yaak_api::{ApiClientKind, yaak_api_client};\nuse yaak_common::platform::get_os_str;\nuse yaak_models::util::UpdateSource;\n\n// Check for updates every hour\nconst MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60;\n\nconst KV_NAMESPACE: &str = \"notifications\";\nconst KV_KEY: &str = \"seen\";\n\n// Create updater struct\npub struct YaakNotifier {\n    last_check: Option<Instant>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"index.ts\")]\npub struct YaakNotification {\n    timestamp: DateTime<Utc>,\n    timeout: Option<f64>,\n    id: String,\n    title: Option<String>,\n    message: String,\n    color: Option<String>,\n    action: Option<YaakNotificationAction>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"index.ts\")]\npub struct YaakNotificationAction {\n    label: String,\n    url: String,\n}\n\nimpl YaakNotifier {\n    pub fn new() -> Self {\n        Self { last_check: None }\n    }\n\n    pub async fn seen<R: Runtime>(&mut self, window: &WebviewWindow<R>, id: &str) -> Result<()> {\n        let app_handle = window.app_handle();\n        let mut seen = get_kv(app_handle).await?;\n        seen.push(id.to_string());\n        debug!(\"Marked notification as seen {}\", id);\n        let seen_json = serde_json::to_string(&seen)?;\n        window.db().set_key_value_raw(\n            KV_NAMESPACE,\n            KV_KEY,\n            seen_json.as_str(),\n            &UpdateSource::from_window_label(window.label()),\n        );\n        Ok(())\n    }\n\n    pub async fn maybe_check<R: Runtime>(&mut self, window: &WebviewWindow<R>) -> Result<()> {\n        let app_handle = window.app_handle();\n        if let Some(i) = self.last_check\n            && i.elapsed().as_secs() < MAX_UPDATE_CHECK_SECONDS\n        {\n            return Ok(());\n        }\n\n        self.last_check = Some(Instant::now());\n\n        if !app_handle.db().get_settings().check_notifications {\n            info!(\"Notifications are disabled. Skipping check.\");\n            return Ok(());\n        }\n\n        debug!(\"Checking for notifications\");\n\n        #[cfg(feature = \"license\")]\n        let license_check = {\n            use yaak_license::{LicenseCheckStatus, check_license};\n            match check_license(window).await {\n                Ok(LicenseCheckStatus::PersonalUse { .. }) => \"personal\",\n                Ok(LicenseCheckStatus::Active { .. }) => \"commercial\",\n                Ok(LicenseCheckStatus::PastDue { .. }) => \"past_due\",\n                Ok(LicenseCheckStatus::Inactive { .. }) => \"invalid_license\",\n                Ok(LicenseCheckStatus::Trialing { .. }) => \"trialing\",\n                Ok(LicenseCheckStatus::Expired { .. }) => \"expired\",\n                Ok(LicenseCheckStatus::Error { .. }) => \"error\",\n                Err(_) => \"unknown\",\n            }\n            .to_string()\n        };\n\n        #[cfg(not(feature = \"license\"))]\n        let license_check = \"disabled\".to_string();\n\n        let launch_info = get_or_upsert_launch_info(app_handle);\n        let app_version = app_handle.package_info().version.to_string();\n        let req = yaak_api_client(ApiClientKind::App, &app_version)?\n            .request(Method::GET, \"https://notify.yaak.app/notifications\")\n            .query(&[\n                (\"version\", &launch_info.current_version),\n                (\"version_prev\", &launch_info.previous_version),\n                (\"launches\", &launch_info.num_launches.to_string()),\n                (\"installed\", &launch_info.user_since.format(\"%Y-%m-%d\").to_string()),\n                (\"license\", &license_check),\n                (\"updates\", &get_updater_status(app_handle).to_string()),\n                (\"platform\", &get_os_str().to_string()),\n            ]);\n        let resp = req.send().await?;\n        if resp.status() != 200 {\n            debug!(\"Skipping notification status code {}\", resp.status());\n            return Ok(());\n        }\n\n        for notification in resp.json::<Vec<YaakNotification>>().await? {\n            let seen = get_kv(app_handle).await?;\n            if seen.contains(&notification.id) {\n                debug!(\"Already seen notification {}\", notification.id);\n                continue;\n            }\n            debug!(\"Got notification {:?}\", notification);\n\n            let _ = app_handle.emit_to(window.label(), \"notification\", notification.clone());\n            break; // Only show one notification\n        }\n\n        Ok(())\n    }\n}\n\nasync fn get_kv<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<String>> {\n    match app_handle.db().get_key_value_raw(\"notifications\", \"seen\") {\n        None => Ok(Vec::new()),\n        Some(v) => Ok(serde_json::from_str(&v.value)?),\n    }\n}\n\n#[allow(unused)]\nfn get_updater_status<R: Runtime>(app_handle: &AppHandle<R>) -> &'static str {\n    #[cfg(not(feature = \"updater\"))]\n    {\n        // Updater is not enabled as a Rust feature\n        return \"missing\";\n    }\n\n    #[cfg(all(feature = \"updater\", target_os = \"linux\"))]\n    {\n        let settings = app_handle.db().get_settings();\n        if !settings.autoupdate {\n            // Updates are explicitly disabled\n            \"disabled\"\n        } else if std::env::var(\"APPIMAGE\").is_err() {\n            // Updates are enabled, but unsupported\n            \"unsupported\"\n        } else {\n            // Updates are enabled and supported\n            \"enabled\"\n        }\n    }\n\n    #[cfg(all(feature = \"updater\", not(target_os = \"linux\")))]\n    {\n        let settings = app_handle.db().get_settings();\n        if settings.autoupdate { \"enabled\" } else { \"disabled\" }\n    }\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/plugin_events.rs",
    "content": "use crate::error::Result;\nuse crate::http_request::send_http_request_with_context;\nuse crate::models_ext::BlobManagerExt;\nuse crate::models_ext::QueryManagerExt;\nuse crate::render::{render_grpc_request, render_http_request, render_json_value};\nuse crate::window::{CreateWindowConfig, create_window};\nuse crate::{\n    call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,\n    workspace_from_window,\n};\nuse chrono::Utc;\nuse cookie::Cookie;\nuse log::error;\nuse std::sync::Arc;\nuse tauri::{AppHandle, Emitter, Listener, Manager, Runtime};\nuse tauri_plugin_clipboard_manager::ClipboardExt;\nuse tauri_plugin_opener::OpenerExt;\nuse yaak::plugin_events::{\n    GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,\n};\nuse yaak_crypto::manager::EncryptionManager;\nuse yaak_models::models::{HttpResponse, Plugin};\nuse yaak_models::queries::any_request::AnyRequest;\nuse yaak_models::util::UpdateSource;\nuse yaak_plugins::error::Error::PluginErr;\nuse yaak_plugins::events::{\n    Color, EmptyPayload, ErrorResponse, GetCookieValueResponse, Icon, InternalEvent,\n    InternalEventPayload, ListCookieNamesResponse, ListOpenWorkspacesResponse,\n    RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,\n    ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent,\n    WorkspaceInfo,\n};\nuse yaak_plugins::manager::PluginManager;\nuse yaak_plugins::plugin_handle::PluginHandle;\nuse yaak_plugins::template_callback::PluginTemplateCallback;\nuse yaak_tauri_utils::window::WorkspaceWindowTrait;\nuse yaak_templates::{RenderErrorBehavior, RenderOptions};\n\npub(crate) async fn handle_plugin_event<R: Runtime>(\n    app_handle: &AppHandle<R>,\n    event: &InternalEvent,\n    plugin_handle: &PluginHandle,\n) -> Result<Option<InternalEventPayload>> {\n    // log::debug!(\"Got event to app {event:?}\");\n    let plugin_context = event.context.to_owned();\n    let plugin_name = plugin_handle.info().name;\n    let fallback_workspace_id = plugin_context.workspace_id.clone().or_else(|| {\n        plugin_context\n            .label\n            .as_ref()\n            .and_then(|label| app_handle.get_webview_window(label))\n            .and_then(|window| workspace_from_window(&window).map(|workspace| workspace.id))\n    });\n\n    match handle_shared_plugin_event(\n        app_handle.db_manager().inner(),\n        &event.payload,\n        SharedPluginEventContext {\n            plugin_name: &plugin_name,\n            workspace_id: fallback_workspace_id.as_deref(),\n        },\n    ) {\n        GroupedPluginEvent::Handled(payload) => Ok(payload),\n        GroupedPluginEvent::ToHandle(host_request) => {\n            handle_host_plugin_request(\n                app_handle,\n                event,\n                plugin_handle,\n                &plugin_context,\n                host_request,\n            )\n            .await\n        }\n    }\n}\n\nasync fn handle_host_plugin_request<R: Runtime>(\n    app_handle: &AppHandle<R>,\n    event: &InternalEvent,\n    plugin_handle: &PluginHandle,\n    plugin_context: &yaak_plugins::events::PluginContext,\n    host_request: HostRequest<'_>,\n) -> Result<Option<InternalEventPayload>> {\n    match host_request {\n        HostRequest::ErrorResponse(resp) => {\n            error!(\"Plugin error: {}: {:?}\", resp.error, resp);\n            let toast_event = plugin_handle.build_event_to_send(\n                plugin_context,\n                &InternalEventPayload::ShowToastRequest(ShowToastRequest {\n                    message: format!(\n                        \"Plugin error from {}: {}\",\n                        plugin_handle.info().name,\n                        resp.error\n                    ),\n                    color: Some(Color::Danger),\n                    timeout: Some(30000),\n                    ..Default::default()\n                }),\n                None,\n            );\n            Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await\n        }\n        HostRequest::ReloadResponse(req) => {\n            let plugins = app_handle.db().list_plugins()?;\n            for plugin in plugins {\n                if plugin.directory != plugin_handle.dir {\n                    continue;\n                }\n\n                let new_plugin = Plugin { updated_at: Utc::now().naive_utc(), ..plugin };\n                app_handle.db().upsert_plugin(&new_plugin, &UpdateSource::Plugin)?;\n            }\n\n            if !req.silent {\n                let info = plugin_handle.info();\n                let toast_event = plugin_handle.build_event_to_send(\n                    plugin_context,\n                    &InternalEventPayload::ShowToastRequest(ShowToastRequest {\n                        message: format!(\"Reloaded plugin {}@{}\", info.name, info.version),\n                        icon: Some(Icon::Info),\n                        timeout: Some(5000),\n                        ..Default::default()\n                    }),\n                    None,\n                );\n                Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await\n            } else {\n                Ok(None)\n            }\n        }\n        HostRequest::CopyText(req) => {\n            app_handle.clipboard().write_text(req.text.as_str())?;\n            Ok(Some(InternalEventPayload::CopyTextResponse(EmptyPayload {})))\n        }\n        HostRequest::ShowToast(req) => {\n            match &plugin_context.label {\n                Some(label) => app_handle.emit_to(label, \"show_toast\", req)?,\n                None => app_handle.emit(\"show_toast\", req)?,\n            };\n            Ok(Some(InternalEventPayload::ShowToastResponse(EmptyPayload {})))\n        }\n        HostRequest::PromptText(_) => {\n            let window = get_window_from_plugin_context(app_handle, plugin_context)?;\n            Ok(call_frontend(&window, event).await)\n        }\n        HostRequest::PromptForm(_) => {\n            let window = get_window_from_plugin_context(app_handle, plugin_context)?;\n            if event.reply_id.is_some() {\n                window.emit_to(window.label(), \"plugin_event\", event.clone())?;\n                Ok(None)\n            } else {\n                window.emit_to(window.label(), \"plugin_event\", event.clone()).unwrap();\n\n                let event_id = event.id.clone();\n                let plugin_handle = plugin_handle.clone();\n                let plugin_context = plugin_context.clone();\n                let window = window.clone();\n\n                tauri::async_runtime::spawn(async move {\n                    let (tx, mut rx) = tokio::sync::mpsc::channel::<InternalEvent>(128);\n\n                    let listener_id = window.listen(event_id, move |ev: tauri::Event| {\n                        let resp: InternalEvent = serde_json::from_str(ev.payload()).unwrap();\n                        let _ = tx.try_send(resp);\n                    });\n\n                    while let Some(resp) = rx.recv().await {\n                        let is_done = matches!(\n                            &resp.payload,\n                            InternalEventPayload::PromptFormResponse(r) if r.done.unwrap_or(false)\n                        );\n\n                        let event_to_send = plugin_handle.build_event_to_send(\n                            &plugin_context,\n                            &resp.payload,\n                            Some(resp.reply_id.unwrap_or_default()),\n                        );\n                        if let Err(e) = plugin_handle.send(&event_to_send).await {\n                            log::warn!(\"Failed to forward form response to plugin: {:?}\", e);\n                        }\n\n                        if is_done {\n                            break;\n                        }\n                    }\n\n                    window.unlisten(listener_id);\n                });\n\n                Ok(None)\n            }\n        }\n        HostRequest::RenderGrpcRequest(req) => {\n            let window = get_window_from_plugin_context(app_handle, plugin_context)?;\n\n            let workspace =\n                workspace_from_window(&window).expect(\"Failed to get workspace_id from window URL\");\n            let environment_id = environment_from_window(&window).map(|e| e.id);\n            let environment_chain = window.db().resolve_environments(\n                &workspace.id,\n                req.grpc_request.folder_id.as_deref(),\n                environment_id.as_deref(),\n            )?;\n            let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());\n            let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());\n            let cb = PluginTemplateCallback::new(\n                plugin_manager,\n                encryption_manager,\n                plugin_context,\n                req.purpose.clone(),\n            );\n            let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n            let grpc_request =\n                render_grpc_request(&req.grpc_request, environment_chain, &cb, &opt).await?;\n            Ok(Some(InternalEventPayload::RenderGrpcRequestResponse(RenderGrpcRequestResponse {\n                grpc_request,\n            })))\n        }\n        HostRequest::RenderHttpRequest(req) => {\n            let window = get_window_from_plugin_context(app_handle, plugin_context)?;\n\n            let workspace =\n                workspace_from_window(&window).expect(\"Failed to get workspace_id from window URL\");\n            let environment_id = environment_from_window(&window).map(|e| e.id);\n            let environment_chain = window.db().resolve_environments(\n                &workspace.id,\n                req.http_request.folder_id.as_deref(),\n                environment_id.as_deref(),\n            )?;\n            let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());\n            let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());\n            let cb = PluginTemplateCallback::new(\n                plugin_manager,\n                encryption_manager,\n                plugin_context,\n                req.purpose.clone(),\n            );\n            let opt = &RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n            let http_request =\n                render_http_request(&req.http_request, environment_chain, &cb, opt).await?;\n            Ok(Some(InternalEventPayload::RenderHttpRequestResponse(RenderHttpRequestResponse {\n                http_request,\n            })))\n        }\n        HostRequest::TemplateRender(req) => {\n            let window = get_window_from_plugin_context(app_handle, plugin_context)?;\n\n            let workspace =\n                workspace_from_window(&window).expect(\"Failed to get workspace_id from window URL\");\n            let environment_id = environment_from_window(&window).map(|e| e.id);\n            let folder_id = if let Some(id) = window.request_id() {\n                match window.db().get_any_request(&id) {\n                    Ok(AnyRequest::HttpRequest(r)) => r.folder_id,\n                    Ok(AnyRequest::GrpcRequest(r)) => r.folder_id,\n                    Ok(AnyRequest::WebsocketRequest(r)) => r.folder_id,\n                    Err(_) => None,\n                }\n            } else {\n                None\n            };\n            let environment_chain = window.db().resolve_environments(\n                &workspace.id,\n                folder_id.as_deref(),\n                environment_id.as_deref(),\n            )?;\n            let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());\n            let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());\n            let cb = PluginTemplateCallback::new(\n                plugin_manager,\n                encryption_manager,\n                plugin_context,\n                req.purpose.clone(),\n            );\n            let opt = RenderOptions { error_behavior: RenderErrorBehavior::Throw };\n            let data = render_json_value(req.data.clone(), environment_chain, &cb, &opt).await?;\n            Ok(Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data })))\n        }\n        HostRequest::SendHttpRequest(req) => {\n            let window = get_window_from_plugin_context(app_handle, plugin_context)?;\n            let mut http_request = req.http_request.clone();\n            let workspace =\n                workspace_from_window(&window).expect(\"Failed to get workspace_id from window URL\");\n            let cookie_jar = cookie_jar_from_window(&window);\n            let environment = environment_from_window(&window);\n\n            if http_request.workspace_id.is_empty() {\n                http_request.workspace_id = workspace.id;\n            }\n\n            let http_response = if http_request.id.is_empty() {\n                HttpResponse::default()\n            } else {\n                let blobs = window.blob_manager();\n                window.db().upsert_http_response(\n                    &HttpResponse {\n                        request_id: http_request.id.clone(),\n                        workspace_id: http_request.workspace_id.clone(),\n                        ..Default::default()\n                    },\n                    &UpdateSource::from_window_label(window.label()),\n                    &blobs,\n                )?\n            };\n\n            let http_response = send_http_request_with_context(\n                &window,\n                &http_request,\n                &http_response,\n                environment,\n                cookie_jar,\n                &mut tokio::sync::watch::channel(false).1,\n                plugin_context,\n            )\n            .await?;\n\n            Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {\n                http_response,\n            })))\n        }\n        HostRequest::OpenWindow(req) => {\n            let (navigation_tx, mut navigation_rx) = tokio::sync::mpsc::channel(128);\n            let (close_tx, mut close_rx) = tokio::sync::mpsc::channel(128);\n            let win_config = CreateWindowConfig {\n                url: &req.url,\n                label: &req.label,\n                title: &req.title.clone().unwrap_or_default(),\n                navigation_tx: Some(navigation_tx),\n                close_tx: Some(close_tx),\n                inner_size: req.size.clone().map(|s| (s.width, s.height)),\n                data_dir_key: req.data_dir_key.clone(),\n                ..Default::default()\n            };\n            if let Err(e) = create_window(app_handle, win_config) {\n                let error_event = plugin_handle.build_event_to_send(\n                    plugin_context,\n                    &InternalEventPayload::ErrorResponse(ErrorResponse {\n                        error: format!(\"Failed to create window: {:?}\", e),\n                    }),\n                    None,\n                );\n                return Box::pin(handle_plugin_event(app_handle, &error_event, plugin_handle))\n                    .await;\n            }\n\n            {\n                let event_id = event.id.clone();\n                let plugin_handle = plugin_handle.clone();\n                let plugin_context = plugin_context.clone();\n                tauri::async_runtime::spawn(async move {\n                    while let Some(url) = navigation_rx.recv().await {\n                        let url = url.to_string();\n                        let event_to_send = plugin_handle.build_event_to_send(\n                            &plugin_context,\n                            &InternalEventPayload::WindowNavigateEvent(WindowNavigateEvent { url }),\n                            Some(event_id.clone()),\n                        );\n                        plugin_handle.send(&event_to_send).await.unwrap();\n                    }\n                });\n            }\n\n            {\n                let event_id = event.id.clone();\n                let plugin_handle = plugin_handle.clone();\n                let plugin_context = plugin_context.clone();\n                tauri::async_runtime::spawn(async move {\n                    while close_rx.recv().await.is_some() {\n                        let event_to_send = plugin_handle.build_event_to_send(\n                            &plugin_context,\n                            &InternalEventPayload::WindowCloseEvent,\n                            Some(event_id.clone()),\n                        );\n                        plugin_handle.send(&event_to_send).await.unwrap();\n                    }\n                });\n            }\n\n            Ok(None)\n        }\n        HostRequest::CloseWindow(req) => {\n            if let Some(window) = app_handle.webview_windows().get(&req.label) {\n                window.close()?;\n            }\n            Ok(None)\n        }\n        HostRequest::OpenExternalUrl(req) => {\n            app_handle.opener().open_url(&req.url, None::<&str>)?;\n            Ok(Some(InternalEventPayload::OpenExternalUrlResponse(EmptyPayload {})))\n        }\n        HostRequest::ListOpenWorkspaces(_) => {\n            let mut workspaces = Vec::new();\n            for (_, window) in app_handle.webview_windows() {\n                if let Some(workspace) = workspace_from_window(&window) {\n                    workspaces.push(WorkspaceInfo {\n                        id: workspace.id.clone(),\n                        name: workspace.name.clone(),\n                        label: window.label().to_string(),\n                    });\n                }\n            }\n            Ok(Some(InternalEventPayload::ListOpenWorkspacesResponse(ListOpenWorkspacesResponse {\n                workspaces,\n            })))\n        }\n        HostRequest::ListCookieNames(_) => {\n            let window = get_window_from_plugin_context(app_handle, plugin_context)?;\n            let names = match cookie_jar_from_window(&window) {\n                None => Vec::new(),\n                Some(j) => j\n                    .cookies\n                    .into_iter()\n                    .filter_map(|c| Cookie::parse(c.raw_cookie).ok().map(|c| c.name().to_string()))\n                    .collect(),\n            };\n            Ok(Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {\n                names,\n            })))\n        }\n        HostRequest::GetCookieValue(req) => {\n            let window = get_window_from_plugin_context(app_handle, plugin_context)?;\n            let value = match cookie_jar_from_window(&window) {\n                None => None,\n                Some(j) => j.cookies.into_iter().find_map(|c| match Cookie::parse(c.raw_cookie) {\n                    Ok(c) if c.name().to_string().eq(&req.name) => {\n                        Some(c.value_trimmed().to_string())\n                    }\n                    _ => None,\n                }),\n            };\n            Ok(Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse { value })))\n        }\n        HostRequest::WindowInfo(req) => {\n            let w = app_handle\n                .get_webview_window(&req.label)\n                .ok_or(PluginErr(format!(\"Failed to find window for {}\", req.label)))?;\n\n            let environment_id = environment_from_window(&w).map(|m| m.id);\n            let workspace_id = workspace_from_window(&w).map(|m| m.id);\n            let request_id =\n                match app_handle.db().get_any_request(&w.request_id().unwrap_or_default()) {\n                    Ok(AnyRequest::HttpRequest(r)) => Some(r.id),\n                    Ok(AnyRequest::WebsocketRequest(r)) => Some(r.id),\n                    Ok(AnyRequest::GrpcRequest(r)) => Some(r.id),\n                    Err(_) => None,\n                };\n\n            Ok(Some(InternalEventPayload::WindowInfoResponse(WindowInfoResponse {\n                label: w.label().to_string(),\n                request_id,\n                workspace_id,\n                environment_id,\n            })))\n        }\n        HostRequest::OtherRequest(req) => {\n            Ok(Some(InternalEventPayload::ErrorResponse(ErrorResponse {\n                error: format!(\n                    \"Unsupported plugin request in app host handler: {}\",\n                    req.type_name()\n                ),\n            })))\n        }\n    }\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/plugins_ext.rs",
    "content": "//! Tauri-specific plugin management code.\n//!\n//! This module contains all Tauri integration for the plugin system:\n//! - Plugin initialization and lifecycle management\n//! - Tauri commands for plugin search/install/uninstall\n//! - Plugin update checking\n\nuse crate::PluginContextExt;\nuse crate::error::Result;\nuse crate::models_ext::QueryManagerExt;\nuse log::{error, info, warn};\nuse serde::Serialize;\nuse std::sync::Arc;\nuse std::sync::atomic::{AtomicBool, Ordering};\nuse std::time::{Duration, Instant};\nuse tauri::path::BaseDirectory;\nuse tauri::plugin::{Builder, TauriPlugin};\nuse tauri::{\n    AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, command,\n    is_dev,\n};\nuse tokio::sync::Mutex;\nuse ts_rs::TS;\nuse yaak_api::{ApiClientKind, yaak_api_client};\nuse yaak_models::models::{Plugin, PluginSource};\nuse yaak_models::util::UpdateSource;\nuse yaak_plugins::api::{\n    PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,\n    search_plugins,\n};\nuse yaak_plugins::events::PluginContext;\nuse yaak_plugins::install::{delete_and_uninstall, download_and_install};\nuse yaak_plugins::manager::PluginManager;\nuse yaak_plugins::plugin_meta::get_plugin_meta;\n\nstatic EXITING: AtomicBool = AtomicBool::new(false);\n\n// ============================================================================\n// Plugin Updater\n// ============================================================================\n\nconst MAX_UPDATE_CHECK_HOURS: u64 = 12;\n\npub struct PluginUpdater {\n    last_check: Option<Instant>,\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"index.ts\")]\npub struct PluginUpdateNotification {\n    pub update_count: usize,\n    pub plugins: Vec<PluginUpdateInfo>,\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"index.ts\")]\npub struct PluginUpdateInfo {\n    pub name: String,\n    pub current_version: String,\n    pub latest_version: String,\n}\n\nimpl PluginUpdater {\n    pub fn new() -> Self {\n        Self { last_check: None }\n    }\n\n    pub async fn check_now<R: Runtime>(&mut self, window: &WebviewWindow<R>) -> Result<bool> {\n        self.last_check = Some(Instant::now());\n\n        info!(\"Checking for plugin updates\");\n\n        let app_version = window.app_handle().package_info().version.to_string();\n        let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;\n        let plugins = window.app_handle().db().list_plugins()?;\n        let updates = check_plugin_updates(&http_client, plugins.clone()).await?;\n\n        if updates.plugins.is_empty() {\n            info!(\"No plugin updates available\");\n            return Ok(false);\n        }\n\n        // Get current plugin versions to build notification\n        let mut update_infos = Vec::new();\n\n        for update in &updates.plugins {\n            if let Some(plugin) = plugins.iter().find(|p| {\n                if let Ok(meta) = get_plugin_meta(&std::path::Path::new(&p.directory)) {\n                    meta.name == update.name\n                } else {\n                    false\n                }\n            }) {\n                if let Ok(meta) = get_plugin_meta(&std::path::Path::new(&plugin.directory)) {\n                    update_infos.push(PluginUpdateInfo {\n                        name: update.name.clone(),\n                        current_version: meta.version,\n                        latest_version: update.version.clone(),\n                    });\n                }\n            }\n        }\n\n        let notification =\n            PluginUpdateNotification { update_count: update_infos.len(), plugins: update_infos };\n\n        info!(\"Found {} plugin update(s)\", notification.update_count);\n\n        if let Err(e) = window.emit_to(window.label(), \"plugin_updates_available\", &notification) {\n            error!(\"Failed to emit plugin_updates_available event: {}\", e);\n        }\n\n        Ok(true)\n    }\n\n    pub async fn maybe_check<R: Runtime>(&mut self, window: &WebviewWindow<R>) -> Result<bool> {\n        let update_period_seconds = MAX_UPDATE_CHECK_HOURS * 60 * 60;\n\n        if let Some(i) = self.last_check\n            && i.elapsed().as_secs() < update_period_seconds\n        {\n            return Ok(false);\n        }\n\n        self.check_now(window).await\n    }\n}\n\n// ============================================================================\n// Tauri Commands\n// ============================================================================\n\n#[command]\npub async fn cmd_plugins_search<R: Runtime>(\n    app_handle: AppHandle<R>,\n    query: &str,\n) -> Result<PluginSearchResponse> {\n    let app_version = app_handle.package_info().version.to_string();\n    let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;\n    Ok(search_plugins(&http_client, query).await?)\n}\n\n#[command]\npub async fn cmd_plugins_install<R: Runtime>(\n    window: WebviewWindow<R>,\n    name: &str,\n    version: Option<String>,\n) -> Result<()> {\n    let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());\n    let app_version = window.app_handle().package_info().version.to_string();\n    let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;\n    let query_manager = window.state::<yaak_models::query_manager::QueryManager>();\n    let plugin_context = window.plugin_context();\n    download_and_install(\n        plugin_manager,\n        &query_manager,\n        &http_client,\n        &plugin_context,\n        name,\n        version,\n    )\n    .await?;\n    Ok(())\n}\n\n#[command]\npub async fn cmd_plugins_install_from_directory<R: Runtime>(\n    window: WebviewWindow<R>,\n    directory: &str,\n) -> Result<Plugin> {\n    let plugin = window.db().upsert_plugin(\n        &Plugin {\n            directory: directory.into(),\n            url: None,\n            enabled: true,\n            source: PluginSource::Filesystem,\n            ..Default::default()\n        },\n        &UpdateSource::from_window_label(window.label()),\n    )?;\n\n    let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());\n    plugin_manager.add_plugin(&window.plugin_context(), &plugin).await?;\n\n    Ok(plugin)\n}\n\n#[command]\npub async fn cmd_plugins_uninstall<R: Runtime>(\n    plugin_id: &str,\n    window: WebviewWindow<R>,\n) -> Result<Plugin> {\n    let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());\n    let query_manager = window.state::<yaak_models::query_manager::QueryManager>();\n    let plugin_context = window.plugin_context();\n    Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)\n}\n\n#[command]\npub async fn cmd_plugin_init_errors(\n    plugin_manager: State<'_, PluginManager>,\n) -> Result<Vec<(String, String)>> {\n    Ok(plugin_manager.take_init_errors().await)\n}\n\n#[command]\npub async fn cmd_plugins_updates<R: Runtime>(\n    app_handle: AppHandle<R>,\n) -> Result<PluginUpdatesResponse> {\n    let app_version = app_handle.package_info().version.to_string();\n    let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;\n    let plugins = app_handle.db().list_plugins()?;\n    Ok(check_plugin_updates(&http_client, plugins).await?)\n}\n\n#[command]\npub async fn cmd_plugins_update_all<R: Runtime>(\n    window: WebviewWindow<R>,\n) -> Result<Vec<PluginNameVersion>> {\n    let app_version = window.app_handle().package_info().version.to_string();\n    let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;\n    let plugins = window.db().list_plugins()?;\n\n    // Get list of available updates (already filtered to only registry plugins)\n    let updates = check_plugin_updates(&http_client, plugins).await?;\n\n    if updates.plugins.is_empty() {\n        return Ok(Vec::new());\n    }\n\n    let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());\n    let query_manager = window.state::<yaak_models::query_manager::QueryManager>();\n    let plugin_context = window.plugin_context();\n\n    let mut updated = Vec::new();\n\n    for update in updates.plugins {\n        info!(\"Updating plugin: {} to version {}\", update.name, update.version);\n        match download_and_install(\n            plugin_manager.clone(),\n            &query_manager,\n            &http_client,\n            &plugin_context,\n            &update.name,\n            Some(update.version.clone()),\n        )\n        .await\n        {\n            Ok(_) => {\n                info!(\"Successfully updated plugin: {}\", update.name);\n                updated.push(update.clone());\n            }\n            Err(e) => {\n                log::error!(\"Failed to update plugin {}: {:?}\", update.name, e);\n            }\n        }\n    }\n\n    Ok(updated)\n}\n\n// ============================================================================\n// Tauri Plugin Initialization\n// ============================================================================\n\npub fn init<R: Runtime>() -> TauriPlugin<R> {\n    Builder::new(\"yaak-plugins\")\n        .setup(|app_handle, _| {\n            // Resolve paths for plugin manager\n            let vendored_plugin_dir = app_handle\n                .path()\n                .resolve(\"vendored/plugins\", BaseDirectory::Resource)\n                .expect(\"failed to resolve plugin directory resource\");\n\n            let installed_plugin_dir = app_handle\n                .path()\n                .app_data_dir()\n                .expect(\"failed to get app data dir\")\n                .join(\"installed-plugins\");\n\n            #[cfg(target_os = \"windows\")]\n            let node_bin_name = \"yaaknode.exe\";\n            #[cfg(not(target_os = \"windows\"))]\n            let node_bin_name = \"yaaknode\";\n\n            let node_bin_path = app_handle\n                .path()\n                .resolve(format!(\"vendored/node/{}\", node_bin_name), BaseDirectory::Resource)\n                .expect(\"failed to resolve yaaknode binary\");\n\n            let plugin_runtime_main = app_handle\n                .path()\n                .resolve(\"vendored/plugin-runtime\", BaseDirectory::Resource)\n                .expect(\"failed to resolve plugin runtime\")\n                .join(\"index.cjs\");\n\n            let dev_mode = is_dev();\n            let query_manager =\n                app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone();\n\n            // Create plugin manager asynchronously\n            let app_handle_clone = app_handle.clone();\n            tauri::async_runtime::block_on(async move {\n                let manager = PluginManager::new(\n                    vendored_plugin_dir,\n                    installed_plugin_dir,\n                    node_bin_path,\n                    plugin_runtime_main,\n                    &query_manager,\n                    &PluginContext::new_empty(),\n                    dev_mode,\n                )\n                .await\n                .expect(\"Failed to start plugin runtime\");\n\n                app_handle_clone.manage(manager);\n            });\n\n            let plugin_updater = PluginUpdater::new();\n            app_handle.manage(Mutex::new(plugin_updater));\n\n            Ok(())\n        })\n        .on_event(|app, e| match e {\n            RunEvent::ExitRequested { api, .. } => {\n                if EXITING.swap(true, Ordering::SeqCst) {\n                    return; // Only exit once to prevent infinite recursion\n                }\n                api.prevent_exit();\n                tauri::async_runtime::block_on(async move {\n                    info!(\"Exiting plugin runtime due to app exit\");\n                    let manager: State<PluginManager> = app.state();\n                    manager.terminate().await;\n                    app.exit(0);\n                });\n            }\n            RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {\n                // Check for plugin updates on window focus\n                let w = app.get_webview_window(&label).unwrap();\n                let h = app.clone();\n                tauri::async_runtime::spawn(async move {\n                    tokio::time::sleep(Duration::from_secs(3)).await;\n                    let val: State<'_, Mutex<PluginUpdater>> = h.state();\n                    if let Err(e) = val.lock().await.maybe_check(&w).await {\n                        warn!(\"Failed to check for plugin updates {e:?}\");\n                    }\n                });\n            }\n            _ => {}\n        })\n        .build()\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/render.rs",
    "content": "use serde_json::Value;\npub use yaak::render::{render_grpc_request, render_http_request};\nuse yaak_models::models::Environment;\nuse yaak_models::render::make_vars_hashmap;\nuse yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};\n\npub async fn render_template<T: TemplateCallback>(\n    template: &str,\n    environment_chain: Vec<Environment>,\n    cb: &T,\n    opt: &RenderOptions,\n) -> yaak_templates::error::Result<String> {\n    let vars = &make_vars_hashmap(environment_chain);\n    parse_and_render(template, vars, cb, &opt).await\n}\n\npub async fn render_json_value<T: TemplateCallback>(\n    value: Value,\n    environment_chain: Vec<Environment>,\n    cb: &T,\n    opt: &RenderOptions,\n) -> yaak_templates::error::Result<Value> {\n    let vars = &make_vars_hashmap(environment_chain);\n    render_json_value_raw(value, vars, cb, opt).await\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/sync_ext.rs",
    "content": "//! Tauri-specific extensions for yaak-sync.\n//!\n//! This module provides the Tauri commands for sync functionality.\n\nuse crate::error::Result;\nuse crate::models_ext::QueryManagerExt;\nuse chrono::Utc;\nuse log::warn;\nuse serde::{Deserialize, Serialize};\nuse std::path::Path;\nuse tauri::ipc::Channel;\nuse tauri::{AppHandle, Listener, Runtime, command};\nuse tokio::sync::watch;\nuse ts_rs::TS;\nuse yaak_sync::error::Error::InvalidSyncDirectory;\nuse yaak_sync::sync::{\n    FsCandidate, SyncOp, apply_sync_ops, apply_sync_state_ops, compute_sync_ops, get_db_candidates,\n    get_fs_candidates,\n};\nuse yaak_sync::watch::{WatchEvent, watch_directory};\n\n#[command]\npub(crate) async fn cmd_sync_calculate<R: Runtime>(\n    app_handle: AppHandle<R>,\n    workspace_id: &str,\n    sync_dir: &Path,\n) -> Result<Vec<SyncOp>> {\n    if !sync_dir.exists() {\n        return Err(InvalidSyncDirectory(sync_dir.to_string_lossy().to_string()).into());\n    }\n\n    let db = app_handle.db();\n    let version = app_handle.package_info().version.to_string();\n    let db_candidates = get_db_candidates(&db, &version, workspace_id, sync_dir)?;\n    let fs_candidates = get_fs_candidates(sync_dir)?\n        .into_iter()\n        // Only keep items in the same workspace\n        .filter(|fs| fs.model.workspace_id() == workspace_id)\n        .collect::<Vec<FsCandidate>>();\n    Ok(compute_sync_ops(db_candidates, fs_candidates))\n}\n\n#[command]\npub(crate) async fn cmd_sync_calculate_fs(dir: &Path) -> Result<Vec<SyncOp>> {\n    let db_candidates = Vec::new();\n    let fs_candidates = get_fs_candidates(dir)?;\n    Ok(compute_sync_ops(db_candidates, fs_candidates))\n}\n\n#[command]\npub(crate) async fn cmd_sync_apply<R: Runtime>(\n    app_handle: AppHandle<R>,\n    sync_ops: Vec<SyncOp>,\n    sync_dir: &Path,\n    workspace_id: &str,\n) -> Result<()> {\n    let db = app_handle.db();\n    let sync_state_ops = apply_sync_ops(&db, workspace_id, sync_dir, sync_ops)?;\n    apply_sync_state_ops(&db, workspace_id, sync_dir, sync_state_ops)?;\n    Ok(())\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"index.ts\")]\npub(crate) struct WatchResult {\n    unlisten_event: String,\n}\n\n#[command]\npub(crate) async fn cmd_sync_watch<R: Runtime>(\n    app_handle: AppHandle<R>,\n    sync_dir: &Path,\n    workspace_id: &str,\n    channel: Channel<WatchEvent>,\n) -> Result<WatchResult> {\n    let (cancel_tx, cancel_rx) = watch::channel(());\n\n    // Create a callback that forwards events to the Tauri channel\n    let callback = move |event: WatchEvent| {\n        if let Err(e) = channel.send(event) {\n            warn!(\"Failed to send watch event: {:?}\", e);\n        }\n    };\n\n    watch_directory(&sync_dir, callback, cancel_rx).await?;\n\n    let app_handle_inner = app_handle.clone();\n    let unlisten_event =\n        format!(\"watch-unlisten-{}-{}\", workspace_id, Utc::now().timestamp_millis());\n\n    // TODO: Figure out a way to unlisten when the client app_handle refreshes or closes. Perhaps with\n    //   a heartbeat mechanism, or ensuring only a single subscription per workspace (at least\n    //   this won't create `n` subs). We could also maybe have a global fs watcher that we keep\n    //   adding to here.\n    app_handle.listen_any(unlisten_event.clone(), move |event| {\n        app_handle_inner.unlisten(event.id());\n        if let Err(e) = cancel_tx.send(()) {\n            warn!(\"Failed to send cancel signal to watcher {e:?}\");\n        }\n    });\n\n    Ok(WatchResult { unlisten_event })\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/updates.rs",
    "content": "use std::fmt::{Display, Formatter};\nuse std::path::PathBuf;\nuse std::time::{Duration, Instant};\n\nuse crate::error::Result;\nuse crate::models_ext::QueryManagerExt;\nuse log::{debug, error, info, warn};\nuse serde::{Deserialize, Serialize};\nuse tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};\nuse tauri_plugin_dialog::{DialogExt, MessageDialogButtons};\nuse tauri_plugin_updater::{Update, UpdaterExt};\nuse tokio::task::block_in_place;\nuse tokio::time::sleep;\nuse ts_rs::TS;\nuse yaak_models::util::generate_id;\nuse yaak_plugins::manager::PluginManager;\n\nuse url::Url;\nuse yaak_api::get_system_proxy_url;\n\nuse crate::error::Error::GenericError;\nuse crate::is_dev;\n\nconst MAX_UPDATE_CHECK_HOURS_STABLE: u64 = 12;\nconst MAX_UPDATE_CHECK_HOURS_BETA: u64 = 3;\nconst MAX_UPDATE_CHECK_HOURS_ALPHA: u64 = 1;\n\n// Create updater struct\npub struct YaakUpdater {\n    last_check: Option<Instant>,\n}\n\npub enum UpdateMode {\n    Stable,\n    Beta,\n    Alpha,\n}\n\nimpl Display for UpdateMode {\n    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n        let s = match self {\n            UpdateMode::Stable => \"stable\",\n            UpdateMode::Beta => \"beta\",\n            UpdateMode::Alpha => \"alpha\",\n        };\n        write!(f, \"{}\", s)\n    }\n}\n\nimpl UpdateMode {\n    pub fn new(mode: &str) -> UpdateMode {\n        match mode {\n            \"beta\" => UpdateMode::Beta,\n            \"alpha\" => UpdateMode::Alpha,\n            _ => UpdateMode::Stable,\n        }\n    }\n}\n\n#[derive(PartialEq)]\npub enum UpdateTrigger {\n    Background,\n    User,\n}\n\nimpl YaakUpdater {\n    pub fn new() -> Self {\n        Self { last_check: None }\n    }\n\n    pub async fn check_now<R: Runtime>(\n        &mut self,\n        window: &WebviewWindow<R>,\n        mode: UpdateMode,\n        auto_download: bool,\n        update_trigger: UpdateTrigger,\n    ) -> Result<bool> {\n        // Only AppImage supports updates on Linux, so skip if it's not\n        #[cfg(target_os = \"linux\")]\n        {\n            if std::env::var(\"APPIMAGE\").is_err() {\n                return Ok(false);\n            }\n        }\n\n        let settings = window.db().get_settings();\n        let update_key = format!(\"{:x}\", md5::compute(settings.id));\n        self.last_check = Some(Instant::now());\n\n        info!(\"Checking for updates mode={} autodl={}\", mode, auto_download);\n\n        let w = window.clone();\n        let mut updater_builder = w.updater_builder();\n        if let Some(proxy_url) = get_system_proxy_url() {\n            if let Ok(url) = Url::parse(&proxy_url) {\n                updater_builder = updater_builder.proxy(url);\n            }\n        }\n        let update_check_result = updater_builder\n            .on_before_exit(move || {\n                // Kill plugin manager before exit or NSIS installer will fail to replace sidecar\n                // while it's running.\n                // NOTE: This is only called on Windows\n                let w = w.clone();\n                block_in_place(|| {\n                    tauri::async_runtime::block_on(async move {\n                        info!(\"Shutting down plugin manager before update\");\n                        let plugin_manager = w.state::<PluginManager>();\n                        plugin_manager.terminate().await;\n                    });\n                });\n            })\n            .header(\"X-Update-Mode\", mode.to_string())?\n            .header(\"X-Update-Key\", update_key)?\n            .header(\n                \"X-Update-Trigger\",\n                match update_trigger {\n                    UpdateTrigger::Background => \"background\",\n                    UpdateTrigger::User => \"user\",\n                },\n            )?\n            .header(\"X-Install-Mode\", detect_install_mode().unwrap_or(\"unknown\"))?\n            .build()?\n            .check()\n            .await;\n\n        let result = match update_check_result? {\n            None => false,\n            Some(update) => {\n                let w = window.clone();\n                tauri::async_runtime::spawn(async move {\n                    // Force native updater if specified (useful if a release broke the UI)\n                    let native_install_mode =\n                        update.raw_json.get(\"install_mode\").map(|v| v.as_str()).unwrap_or_default()\n                            == Some(\"native\");\n                    if native_install_mode {\n                        start_native_update(&w, &update).await;\n                        return;\n                    }\n\n                    // If it's a background update, try downloading it first\n                    if update_trigger == UpdateTrigger::Background && auto_download {\n                        info!(\"Downloading update {} in background\", update.version);\n                        if let Err(e) = download_update_idempotent(&w, &update).await {\n                            error!(\"Failed to download {}: {}\", update.version, e);\n                        }\n                    }\n\n                    match start_integrated_update(&w, &update).await {\n                        Ok(UpdateResponseAction::Skip) => {\n                            info!(\"Confirmed {}: skipped\", update.version);\n                        }\n                        Ok(UpdateResponseAction::Install) => {\n                            info!(\"Confirmed {}: install\", update.version);\n                            if let Err(e) = install_update_maybe_download(&w, &update).await {\n                                error!(\"Failed to install: {e}\");\n                                return;\n                            };\n\n                            info!(\"Installed {}\", update.version);\n                            finish_integrated_update(&w, &update).await;\n                        }\n                        Err(e) => {\n                            warn!(\"Failed to notify frontend, falling back: {e}\",);\n                            start_native_update(&w, &update).await;\n                        }\n                    };\n                });\n                true\n            }\n        };\n\n        Ok(result)\n    }\n    pub async fn maybe_check<R: Runtime>(\n        &mut self,\n        window: &WebviewWindow<R>,\n        auto_download: bool,\n        mode: UpdateMode,\n    ) -> Result<bool> {\n        let update_period_seconds = match mode {\n            UpdateMode::Stable => MAX_UPDATE_CHECK_HOURS_STABLE,\n            UpdateMode::Beta => MAX_UPDATE_CHECK_HOURS_BETA,\n            UpdateMode::Alpha => MAX_UPDATE_CHECK_HOURS_ALPHA,\n        } * (60 * 60);\n\n        if let Some(i) = self.last_check\n            && i.elapsed().as_secs() < update_period_seconds\n        {\n            return Ok(false);\n        }\n\n        // Don't check if development (can still with manual user trigger)\n        if is_dev() {\n            return Ok(false);\n        }\n\n        self.check_now(window, mode, auto_download, UpdateTrigger::Background).await\n    }\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Default, TS)]\n#[serde(default, rename_all = \"camelCase\")]\n#[ts(export, export_to = \"index.ts\")]\nstruct UpdateInfo {\n    reply_event_id: String,\n    version: String,\n    downloaded: bool,\n}\n\n#[derive(Debug, Clone, PartialEq, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\", tag = \"type\")]\n#[ts(export, export_to = \"index.ts\")]\nenum UpdateResponse {\n    Ack,\n    Action { action: UpdateResponseAction },\n}\n\n#[derive(Debug, Clone, PartialEq, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\")]\n#[ts(export, export_to = \"index.ts\")]\nenum UpdateResponseAction {\n    Install,\n    Skip,\n}\n\nasync fn finish_integrated_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {\n    if let Err(e) = window.emit_to(window.label(), \"update_installed\", update.version.to_string()) {\n        warn!(\"Failed to notify frontend of update install: {}\", e);\n    }\n}\n\nasync fn start_integrated_update<R: Runtime>(\n    window: &WebviewWindow<R>,\n    update: &Update,\n) -> Result<UpdateResponseAction> {\n    let download_path = ensure_download_path(window, update)?;\n    debug!(\"Download path: {}\", download_path.display());\n    let downloaded = download_path.exists();\n    let ack_wait = Duration::from_secs(3);\n    let reply_id = generate_id();\n\n    // 1) Start listening BEFORE emitting to avoid missing a fast reply\n    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<UpdateResponse>();\n    let w_for_listener = window.clone();\n\n    let event_id = w_for_listener.listen(reply_id.clone(), move |ev| {\n        match serde_json::from_str::<UpdateResponse>(ev.payload()) {\n            Ok(UpdateResponse::Ack) => {\n                let _ = tx.send(UpdateResponse::Ack);\n            }\n            Ok(UpdateResponse::Action { action }) => {\n                let _ = tx.send(UpdateResponse::Action { action });\n            }\n            Err(e) => {\n                warn!(\"Failed to parse update reply from frontend: {e:?}\");\n            }\n        }\n    });\n\n    // Make sure we always unlisten\n    struct Unlisten<'a, R: Runtime> {\n        win: &'a WebviewWindow<R>,\n        id: tauri::EventId,\n    }\n    impl<'a, R: Runtime> Drop for Unlisten<'a, R> {\n        fn drop(&mut self) {\n            self.win.unlisten(self.id);\n        }\n    }\n    let _guard = Unlisten { win: window, id: event_id };\n\n    // 2) Emit the event now that listener is in place\n    let info =\n        UpdateInfo { version: update.version.to_string(), downloaded, reply_event_id: reply_id };\n    window\n        .emit_to(window.label(), \"update_available\", &info)\n        .map_err(|e| GenericError(format!(\"Failed to emit update_available: {e}\")))?;\n\n    // 3) Two-stage timeout: first wait for ack, then wait for final action\n    // --- Phase 1: wait for ACK with timeout ---\n    let ack_timer = sleep(ack_wait);\n    tokio::pin!(ack_timer);\n\n    loop {\n        tokio::select! {\n            msg = rx.recv() => match msg {\n                Some(UpdateResponse::Ack) => break, // proceed to Phase 2\n                Some(UpdateResponse::Action{action}) => return Ok(action), // user was fast\n                None => return Err(GenericError(\"frontend channel closed before ack\".into())),\n            },\n            _ = &mut ack_timer => {\n                return Err(GenericError(\"timed out waiting for frontend ack\".into()));\n            }\n        }\n    }\n\n    // --- Phase 2: wait forever for final action ---\n    loop {\n        match rx.recv().await {\n            Some(UpdateResponse::Action { action }) => return Ok(action),\n            Some(UpdateResponse::Ack) => { /* ignore extra acks */ }\n            None => return Err(GenericError(\"frontend channel closed before action\".into())),\n        }\n    }\n}\n\nasync fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {\n    // If the frontend doesn't respond, fallback to native dialogs\n    let confirmed = window\n        .dialog()\n        .message(format!(\n            \"{} is available. Would you like to download and install it now?\",\n            update.version\n        ))\n        .buttons(MessageDialogButtons::OkCancelCustom(\"Download\".to_string(), \"Later\".to_string()))\n        .title(\"Update Available\")\n        .blocking_show();\n    if !confirmed {\n        return;\n    }\n\n    match update.download_and_install(|_, _| {}, || {}).await {\n        Ok(()) => {\n            if window\n                .dialog()\n                .message(\"Would you like to restart the app?\")\n                .title(\"Update Installed\")\n                .buttons(MessageDialogButtons::OkCancelCustom(\n                    \"Restart\".to_string(),\n                    \"Later\".to_string(),\n                ))\n                .blocking_show()\n            {\n                window.app_handle().request_restart();\n            }\n        }\n        Err(e) => {\n            window.dialog().message(format!(\"The update failed to install: {}\", e));\n        }\n    }\n}\n\npub async fn download_update_idempotent<R: Runtime>(\n    window: &WebviewWindow<R>,\n    update: &Update,\n) -> Result<PathBuf> {\n    let dl_path = ensure_download_path(window, update)?;\n\n    if dl_path.exists() {\n        info!(\"{} already downloaded to {}\", update.version, dl_path.display());\n        return Ok(dl_path);\n    }\n\n    info!(\"{} downloading: {}\", update.version, dl_path.display());\n    let dl_bytes = update.download(|_, _| {}, || {}).await?;\n    std::fs::write(&dl_path, dl_bytes)\n        .map_err(|e| GenericError(format!(\"Failed to write update: {e}\")))?;\n\n    info!(\"{} downloaded\", update.version);\n\n    Ok(dl_path)\n}\n\n/// Detect the installer type so the update server can serve the correct artifact.\nfn detect_install_mode() -> Option<&'static str> {\n    #[cfg(target_os = \"windows\")]\n    {\n        if let Ok(exe) = std::env::current_exe() {\n            let path = exe.to_string_lossy().to_lowercase();\n            if path.starts_with(r\"c:\\program files\") {\n                return Some(\"nsis-machine\");\n            }\n        }\n        return Some(\"nsis\");\n    }\n    #[allow(unreachable_code)]\n    None\n}\n\npub async fn install_update_maybe_download<R: Runtime>(\n    window: &WebviewWindow<R>,\n    update: &Update,\n) -> Result<()> {\n    let dl_path = download_update_idempotent(window, update).await?;\n    let update_bytes = std::fs::read(&dl_path)?;\n    update.install(update_bytes.as_slice())?;\n    Ok(())\n}\n\npub fn ensure_download_path<R: Runtime>(\n    window: &WebviewWindow<R>,\n    update: &Update,\n) -> Result<PathBuf> {\n    // Ensure dir exists\n    let base_dir = window.path().app_cache_dir()?.join(\"updates\");\n    std::fs::create_dir_all(&base_dir)?;\n\n    // Generate name based on signature\n    let sig_digest = md5::compute(&update.signature);\n    let name = format!(\"yaak-{}-{:x}\", update.version, sig_digest);\n    let dl_path = base_dir.join(name);\n\n    Ok(dl_path)\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/uri_scheme.rs",
    "content": "use crate::PluginContextExt;\nuse crate::error::Result;\nuse crate::import::import_data;\nuse crate::models_ext::QueryManagerExt;\nuse log::{info, warn};\nuse std::collections::HashMap;\nuse std::fs;\nuse std::sync::Arc;\nuse tauri::{AppHandle, Emitter, Manager, Runtime, Url};\nuse tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};\nuse yaak_api::{ApiClientKind, yaak_api_client};\nuse yaak_models::util::generate_id;\nuse yaak_plugins::events::{Color, ShowToastRequest};\nuse yaak_plugins::install::download_and_install;\nuse yaak_plugins::manager::PluginManager;\n\npub(crate) async fn handle_deep_link<R: Runtime>(\n    app_handle: &AppHandle<R>,\n    url: &Url,\n) -> Result<()> {\n    let command = url.domain().unwrap_or_default();\n    info!(\"Yaak URI scheme invoked {}?{}\", command, url.query().unwrap_or_default());\n\n    let query_map: HashMap<String, String> = url.query_pairs().into_owned().collect();\n    let windows = app_handle.webview_windows();\n    let (_, window) = windows.iter().next().unwrap();\n\n    match command {\n        \"install-plugin\" => {\n            let name = query_map.get(\"name\").unwrap();\n            let version = query_map.get(\"version\").cloned();\n            _ = window.set_focus();\n            let confirmed_install = app_handle\n                .dialog()\n                .message(format!(\"Install plugin {name} {version:?}?\"))\n                .kind(MessageDialogKind::Info)\n                .buttons(MessageDialogButtons::OkCancelCustom(\n                    \"Install\".to_string(),\n                    \"Cancel\".to_string(),\n                ))\n                .blocking_show();\n            if !confirmed_install {\n                // Cancelled installation\n                return Ok(());\n            }\n\n            let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());\n            let query_manager = app_handle.db_manager();\n            let app_version = app_handle.package_info().version.to_string();\n            let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;\n            let plugin_context = window.plugin_context();\n            let pv = download_and_install(\n                plugin_manager,\n                &query_manager,\n                &http_client,\n                &plugin_context,\n                name,\n                version,\n            )\n            .await?;\n            app_handle.emit(\n                \"show_toast\",\n                ShowToastRequest {\n                    message: format!(\"Installed {name}@{}\", pv.version),\n                    color: Some(Color::Success),\n                    icon: None,\n                    timeout: Some(5000),\n                },\n            )?;\n        }\n        \"import-data\" => {\n            let mut file_path = query_map.get(\"path\").map(|s| s.to_owned());\n            let name = query_map.get(\"name\").map(|s| s.to_owned()).unwrap_or(\"data\".to_string());\n            _ = window.set_focus();\n\n            if let Some(file_url) = query_map.get(\"url\") {\n                let confirmed_import = app_handle\n                    .dialog()\n                    .message(format!(\"Import {name} from {file_url}?\"))\n                    .kind(MessageDialogKind::Info)\n                    .buttons(MessageDialogButtons::OkCancelCustom(\n                        \"Import\".to_string(),\n                        \"Cancel\".to_string(),\n                    ))\n                    .blocking_show();\n                if !confirmed_import {\n                    return Ok(());\n                }\n\n                let app_version = app_handle.package_info().version.to_string();\n                let resp =\n                    yaak_api_client(ApiClientKind::App, &app_version)?.get(file_url).send().await?;\n                let json = resp.bytes().await?;\n                let p = app_handle\n                    .path()\n                    .temp_dir()?\n                    .join(format!(\"import-{}\", generate_id()))\n                    .to_string_lossy()\n                    .to_string();\n                fs::write(&p, json)?;\n                file_path = Some(p);\n            }\n\n            let file_path = match file_path {\n                Some(p) => p,\n                None => {\n                    app_handle.emit(\n                        \"show_toast\",\n                        ShowToastRequest {\n                            message: \"Failed to import data\".to_string(),\n                            color: Some(Color::Danger),\n                            icon: None,\n                            timeout: None,\n                        },\n                    )?;\n                    return Ok(());\n                }\n            };\n\n            let results = import_data(window, &file_path).await?;\n            window.emit(\n                \"show_toast\",\n                ShowToastRequest {\n                    message: format!(\"Imported data for {} workspaces\", results.workspaces.len()),\n                    color: Some(Color::Success),\n                    icon: None,\n                    timeout: Some(5000),\n                },\n            )?;\n        }\n        _ => {\n            warn!(\"Unknown deep link command: {command}\");\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/window.rs",
    "content": "use crate::error::Result;\nuse crate::models_ext::QueryManagerExt;\nuse crate::window_menu::app_menu;\nuse log::{info, warn};\nuse rand::random;\nuse tauri::{\n    AppHandle, Emitter, LogicalSize, Manager, PhysicalSize, Runtime, WebviewUrl, WebviewWindow,\n    WindowEvent,\n};\nuse tauri_plugin_opener::OpenerExt;\nuse tokio::sync::mpsc;\n\nconst DEFAULT_WINDOW_WIDTH: f64 = 1100.0;\nconst DEFAULT_WINDOW_HEIGHT: f64 = 600.0;\n\nconst MIN_WINDOW_WIDTH: f64 = 300.0;\nconst MIN_WINDOW_HEIGHT: f64 = 300.0;\n\npub(crate) const MAIN_WINDOW_PREFIX: &str = \"main_\";\nconst OTHER_WINDOW_PREFIX: &str = \"other_\";\n\n#[derive(Default, Debug)]\npub(crate) struct CreateWindowConfig<'s> {\n    pub url: &'s str,\n    pub label: &'s str,\n    pub title: &'s str,\n    pub inner_size: Option<(f64, f64)>,\n    pub position: Option<(f64, f64)>,\n    pub navigation_tx: Option<mpsc::Sender<String>>,\n    pub close_tx: Option<mpsc::Sender<()>>,\n    pub data_dir_key: Option<String>,\n    pub hide_titlebar: bool,\n}\n\npub(crate) fn create_window<R: Runtime>(\n    handle: &AppHandle<R>,\n    config: CreateWindowConfig,\n) -> Result<WebviewWindow<R>> {\n    #[allow(unused_variables)]\n    let menu = app_menu(handle)?;\n\n    // This causes the window to not be clickable (in AppImage), so disable on Linux\n    #[cfg(not(target_os = \"linux\"))]\n    handle.set_menu(menu).expect(\"Failed to set app menu\");\n\n    info!(\"Create new window label={}\", config.label);\n\n    let mut win_builder =\n        tauri::WebviewWindowBuilder::new(handle, config.label, WebviewUrl::App(config.url.into()))\n            .title(config.title)\n            .resizable(true)\n            .visible(false) // To prevent theme flashing, the frontend code calls show() immediately after configuring the theme\n            .fullscreen(false)\n            .min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT);\n\n    if let Some(key) = config.data_dir_key {\n        #[cfg(not(target_os = \"macos\"))]\n        {\n            use std::fs;\n            let safe_key = format!(\"{:x}\", md5::compute(key.as_bytes()));\n            let dir = handle.path().app_data_dir()?.join(\"window-sessions\").join(safe_key);\n            fs::create_dir_all(&dir)?;\n            win_builder = win_builder.data_directory(dir);\n        }\n\n        // macOS doesn't support `data_directory()` so must use this fn instead\n        #[cfg(target_os = \"macos\")]\n        {\n            let hash = md5::compute(key.as_bytes());\n            let mut id = [0u8; 16];\n            id.copy_from_slice(&hash[..16]); // Take the first 16 bytes of the hash\n            win_builder = win_builder.data_store_identifier(id);\n        }\n    }\n\n    if let Some((w, h)) = config.inner_size {\n        win_builder = win_builder.inner_size(w, h);\n    } else {\n        win_builder = win_builder.inner_size(600.0, 600.0);\n    }\n\n    if let Some((x, y)) = config.position {\n        win_builder = win_builder.position(x, y);\n    } else {\n        win_builder = win_builder.center();\n    }\n\n    if let Some(tx) = config.navigation_tx {\n        win_builder = win_builder.on_navigation(move |url| {\n            let url = url.to_string();\n            let tx = tx.clone();\n            tauri::async_runtime::block_on(async move {\n                tx.send(url).await.unwrap();\n            });\n            true\n        });\n    }\n\n    let settings = handle.db().get_settings();\n    if config.hide_titlebar && !settings.use_native_titlebar {\n        #[cfg(target_os = \"macos\")]\n        {\n            use tauri::TitleBarStyle;\n            win_builder = win_builder.hidden_title(true).title_bar_style(TitleBarStyle::Overlay);\n        }\n        #[cfg(not(target_os = \"macos\"))]\n        {\n            win_builder = win_builder.decorations(false);\n        }\n    }\n\n    if let Some(w) = handle.webview_windows().get(config.label) {\n        info!(\"Webview with label {} already exists. Focusing existing\", config.label);\n        w.set_focus()?;\n        return Ok(w.to_owned());\n    }\n\n    let win = win_builder.build()?;\n\n    if let Some(tx) = config.close_tx {\n        win.on_window_event(move |event| match event {\n            WindowEvent::CloseRequested { .. } => {\n                let tx = tx.clone();\n                tauri::async_runtime::spawn(async move {\n                    tx.send(()).await.unwrap();\n                });\n            }\n            _ => {}\n        });\n    }\n\n    let webview_window = win.clone();\n    win.on_menu_event(move |w, event| {\n        if !w.is_focused().unwrap() {\n            return;\n        }\n\n        let event_id = event.id().0.as_str();\n        match event_id {\n            \"hacked_quit\" => {\n                // Cmd+Q on macOS doesn't trigger `CloseRequested` so we use a custom Quit menu\n                // and trigger close() for each window.\n                w.webview_windows().iter().for_each(|(_, w)| {\n                    info!(\"Closing window {}\", w.label());\n                    let _ = w.close();\n                });\n            }\n            \"close\" => w.close().unwrap(),\n            \"zoom_reset\" => w.emit(\"zoom_reset\", true).unwrap(),\n            \"zoom_in\" => w.emit(\"zoom_in\", true).unwrap(),\n            \"zoom_out\" => w.emit(\"zoom_out\", true).unwrap(),\n            \"settings\" => w.emit(\"settings\", true).unwrap(),\n            \"open_feedback\" => {\n                if let Err(e) =\n                    w.app_handle().opener().open_url(\"https://yaak.app/feedback\", None::<&str>)\n                {\n                    warn!(\"Failed to open feedback {e:?}\")\n                }\n            }\n\n            // Commands for development\n            \"dev.reset_size\" => webview_window\n                .set_size(LogicalSize::new(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT))\n                .unwrap(),\n            \"dev.reset_size_16x9\" => {\n                let width = webview_window.outer_size().unwrap().width;\n                let height = width * 9 / 16;\n                webview_window.set_size(PhysicalSize::new(width, height)).unwrap()\n            }\n            \"dev.reset_size_16x10\" => {\n                let width = webview_window.outer_size().unwrap().width;\n                let height = width * 10 / 16;\n                webview_window.set_size(PhysicalSize::new(width, height)).unwrap()\n            }\n            \"dev.refresh\" => webview_window.eval(\"location.reload()\").unwrap(),\n            \"dev.generate_theme_css\" => {\n                w.emit(\"generate_theme_css\", true).unwrap();\n            }\n            \"dev.toggle_devtools\" => {\n                if webview_window.is_devtools_open() {\n                    webview_window.close_devtools();\n                } else {\n                    webview_window.open_devtools();\n                }\n            }\n            _ => {}\n        }\n    });\n\n    Ok(win)\n}\n\npub(crate) fn create_main_window(handle: &AppHandle, url: &str) -> Result<WebviewWindow> {\n    let mut counter = 0;\n    let label = loop {\n        let label = format!(\"{MAIN_WINDOW_PREFIX}{counter}\");\n        match handle.webview_windows().get(label.as_str()) {\n            None => break Some(label),\n            Some(_) => counter += 1,\n        }\n    }\n    .expect(\"Failed to generate label for new window\");\n\n    let config = CreateWindowConfig {\n        url,\n        label: label.as_str(),\n        title: \"Yaak\",\n        inner_size: Some((DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)),\n        position: Some((\n            // Offset by random amount so it's easier to differentiate\n            100.0 + random::<f64>() * 20.0,\n            100.0 + random::<f64>() * 20.0,\n        )),\n        hide_titlebar: true,\n        ..Default::default()\n    };\n\n    create_window(handle, config)\n}\n\npub(crate) fn create_child_window(\n    parent_window: &WebviewWindow,\n    url: &str,\n    label: &str,\n    title: &str,\n    inner_size: (f64, f64),\n) -> Result<WebviewWindow> {\n    let app_handle = parent_window.app_handle();\n    let label = format!(\"{OTHER_WINDOW_PREFIX}_{label}\");\n    let scale_factor = parent_window.scale_factor()?;\n\n    let current_pos = parent_window.inner_position()?.to_logical::<f64>(scale_factor);\n    let current_size = parent_window.inner_size()?.to_logical::<f64>(scale_factor);\n\n    // Position the new window in the middle of the parent\n    let position = (\n        current_pos.x + current_size.width / 2.0 - inner_size.0 / 2.0,\n        current_pos.y + current_size.height / 2.0 - inner_size.1 / 2.0,\n    );\n\n    let config = CreateWindowConfig {\n        label: label.as_str(),\n        title,\n        url,\n        inner_size: Some(inner_size),\n        position: Some(position),\n        hide_titlebar: true,\n        ..Default::default()\n    };\n\n    let child_window = create_window(&app_handle, config)?;\n\n    // NOTE: These listeners will remain active even when the windows close. Unfortunately,\n    //   there's no way to unlisten to events for now, so we just have to be defensive.\n\n    {\n        let parent_window = parent_window.clone();\n        let child_window = child_window.clone();\n        child_window.clone().on_window_event(move |e| match e {\n            // When the new window is destroyed, bring the other up behind it\n            WindowEvent::Destroyed => {\n                if let Some(w) = parent_window.get_webview_window(child_window.label()) {\n                    w.set_focus().unwrap();\n                }\n            }\n            _ => {}\n        });\n    }\n\n    {\n        let parent_window = parent_window.clone();\n        let child_window = child_window.clone();\n        parent_window.clone().on_window_event(move |e| match e {\n            // When the parent window is closed, close the child\n            WindowEvent::CloseRequested { .. } => child_window.close().unwrap(),\n            // When the parent window is focused, bring the child above\n            WindowEvent::Focused(focus) => {\n                if *focus {\n                    if let Some(w) = parent_window.get_webview_window(child_window.label()) {\n                        w.set_focus().unwrap();\n                    };\n                }\n            }\n            _ => {}\n        });\n    }\n\n    Ok(child_window)\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/window_menu.rs",
    "content": "pub use tauri::AppHandle;\nuse tauri::Runtime;\nuse tauri::menu::{\n    AboutMetadata, HELP_SUBMENU_ID, Menu, MenuItemBuilder, PredefinedMenuItem, Submenu,\n    WINDOW_SUBMENU_ID,\n};\n\npub fn app_menu<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>> {\n    let pkg_info = app_handle.package_info();\n    let config = app_handle.config();\n    let about_metadata = AboutMetadata {\n        name: Some(pkg_info.name.clone()),\n        version: Some(pkg_info.version.to_string()),\n        copyright: config.bundle.copyright.clone(),\n        authors: config.bundle.publisher.clone().map(|p| vec![p]),\n        ..Default::default()\n    };\n\n    let window_menu = Submenu::with_id_and_items(\n        app_handle,\n        WINDOW_SUBMENU_ID,\n        \"Window\",\n        true,\n        &[\n            &PredefinedMenuItem::minimize(app_handle, None)?,\n            &PredefinedMenuItem::maximize(app_handle, None)?,\n            #[cfg(target_os = \"macos\")]\n            &PredefinedMenuItem::separator(app_handle)?,\n            &PredefinedMenuItem::close_window(app_handle, None)?,\n        ],\n    )?;\n\n    #[cfg(target_os = \"macos\")]\n    {\n        window_menu.set_as_windows_menu_for_nsapp()?;\n    }\n\n    let help_menu = Submenu::with_id_and_items(\n        app_handle,\n        HELP_SUBMENU_ID,\n        \"Help\",\n        true,\n        &[\n            #[cfg(not(target_os = \"macos\"))]\n            &PredefinedMenuItem::about(app_handle, None, Some(about_metadata.clone()))?,\n            #[cfg(target_os = \"macos\")]\n            &MenuItemBuilder::with_id(\"open_feedback\".to_string(), \"Give Feedback\")\n                .build(app_handle)?,\n        ],\n    )?;\n\n    #[cfg(target_os = \"macos\")]\n    {\n        help_menu.set_as_windows_menu_for_nsapp()?;\n    }\n\n    let menu = Menu::with_items(\n        app_handle,\n        &[\n            #[cfg(target_os = \"macos\")]\n            &Submenu::with_items(\n                app_handle,\n                pkg_info.name.clone(),\n                true,\n                &[\n                    &PredefinedMenuItem::about(app_handle, None, Some(about_metadata))?,\n                    &PredefinedMenuItem::separator(app_handle)?,\n                    &MenuItemBuilder::with_id(\"settings\".to_string(), \"Settings\")\n                        .accelerator(\"CmdOrCtrl+,\")\n                        .build(app_handle)?,\n                    &PredefinedMenuItem::separator(app_handle)?,\n                    &PredefinedMenuItem::services(app_handle, None)?,\n                    &PredefinedMenuItem::separator(app_handle)?,\n                    &PredefinedMenuItem::hide(app_handle, None)?,\n                    &PredefinedMenuItem::hide_others(app_handle, None)?,\n                    &PredefinedMenuItem::separator(app_handle)?,\n                    // NOTE: Replace the predefined quit item with a custom one because, for some\n                    //  reason, ExitRequested events are not fired on cmd+Q. Perhaps this will be\n                    //  fixed in the future?\n                    //  https://github.com/tauri-apps/tauri/issues/9198\n                    &MenuItemBuilder::with_id(\n                        \"hacked_quit\".to_string(),\n                        format!(\"Quit {}\", app_handle.package_info().name),\n                    )\n                    .accelerator(\"CmdOrCtrl+q\")\n                    .build(app_handle)?,\n                ],\n            )?,\n            #[cfg(not(any(\n                target_os = \"linux\",\n                target_os = \"dragonfly\",\n                target_os = \"freebsd\",\n                target_os = \"netbsd\",\n                target_os = \"openbsd\"\n            )))]\n            &Submenu::with_items(\n                app_handle,\n                \"File\",\n                true,\n                &[\n                    &PredefinedMenuItem::close_window(app_handle, None)?,\n                    #[cfg(not(target_os = \"macos\"))]\n                    &PredefinedMenuItem::quit(app_handle, None)?,\n                ],\n            )?,\n            &Submenu::with_items(\n                app_handle,\n                \"Edit\",\n                true,\n                &[\n                    &PredefinedMenuItem::undo(app_handle, None)?,\n                    &PredefinedMenuItem::redo(app_handle, None)?,\n                    &PredefinedMenuItem::separator(app_handle)?,\n                    &PredefinedMenuItem::cut(app_handle, None)?,\n                    &PredefinedMenuItem::copy(app_handle, None)?,\n                    &PredefinedMenuItem::paste(app_handle, None)?,\n                    &PredefinedMenuItem::select_all(app_handle, None)?,\n                ],\n            )?,\n            &Submenu::with_items(\n                app_handle,\n                \"View\",\n                true,\n                &[\n                    #[cfg(target_os = \"macos\")]\n                    &PredefinedMenuItem::fullscreen(app_handle, None)?,\n                    #[cfg(target_os = \"macos\")]\n                    &PredefinedMenuItem::separator(app_handle)?,\n                    &MenuItemBuilder::with_id(\"zoom_reset\".to_string(), \"Zoom to Actual Size\")\n                        .accelerator(\"CmdOrCtrl+0\")\n                        .build(app_handle)?,\n                    &MenuItemBuilder::with_id(\"zoom_in\".to_string(), \"Zoom In\")\n                        .accelerator(\"CmdOrCtrl+=\")\n                        .build(app_handle)?,\n                    &MenuItemBuilder::with_id(\"zoom_out\".to_string(), \"Zoom Out\")\n                        .accelerator(\"CmdOrCtrl+-\")\n                        .build(app_handle)?,\n                ],\n            )?,\n            &window_menu,\n            &help_menu,\n            #[cfg(dev)]\n            &Submenu::with_items(\n                app_handle,\n                \"Develop\",\n                true,\n                &[\n                    &MenuItemBuilder::with_id(\"dev.refresh\".to_string(), \"Refresh\")\n                        .accelerator(\"CmdOrCtrl+Shift+r\")\n                        .build(app_handle)?,\n                    &MenuItemBuilder::with_id(\"dev.toggle_devtools\".to_string(), \"Open Devtools\")\n                        .accelerator(\"CmdOrCtrl+Option+i\")\n                        .build(app_handle)?,\n                    &MenuItemBuilder::with_id(\"dev.reset_size\".to_string(), \"Reset Size\")\n                        .build(app_handle)?,\n                    &MenuItemBuilder::with_id(\"dev.reset_size_16x9\".to_string(), \"Resize to 16x9\")\n                        .build(app_handle)?,\n                    &MenuItemBuilder::with_id(\n                        \"dev.reset_size_16x10\".to_string(),\n                        \"Resize to 16x10\",\n                    )\n                    .build(app_handle)?,\n                    &MenuItemBuilder::with_id(\n                        \"dev.generate_theme_css\".to_string(),\n                        \"Generate Theme CSS\",\n                    )\n                    .build(app_handle)?,\n                ],\n            )?,\n        ],\n    )?;\n\n    Ok(menu)\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/src/ws_ext.rs",
    "content": "//! WebSocket Tauri command wrappers\n//! These wrap the core yaak-ws functionality for Tauri IPC.\n\nuse crate::PluginContextExt;\nuse crate::error::Result;\nuse crate::models_ext::QueryManagerExt;\nuse http::HeaderMap;\nuse log::{debug, info, warn};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::http::HeaderValue;\nuse tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command};\nuse tokio::sync::{Mutex, mpsc};\nuse tokio_tungstenite::tungstenite::Message;\nuse url::Url;\nuse yaak_crypto::manager::EncryptionManager;\nuse yaak_http::cookies::CookieStore;\nuse yaak_http::path_placeholders::apply_path_placeholders;\nuse yaak_models::models::{\n    HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,\n    WebsocketEventType, WebsocketRequest,\n};\nuse yaak_models::util::UpdateSource;\nuse yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};\nuse yaak_plugins::manager::PluginManager;\nuse yaak_plugins::template_callback::PluginTemplateCallback;\nuse yaak_templates::strip_json_comments::maybe_strip_json_comments;\nuse yaak_templates::{RenderErrorBehavior, RenderOptions};\nuse yaak_tls::find_client_certificate;\nuse yaak_ws::{WebsocketManager, render_websocket_request};\n\n#[command]\npub async fn cmd_ws_delete_connections<R: Runtime>(\n    request_id: &str,\n    app_handle: AppHandle<R>,\n    window: WebviewWindow<R>,\n) -> Result<()> {\n    Ok(app_handle.db().delete_all_websocket_connections_for_request(\n        request_id,\n        &UpdateSource::from_window_label(window.label()),\n    )?)\n}\n\n#[command]\npub async fn cmd_ws_send<R: Runtime>(\n    connection_id: &str,\n    environment_id: Option<&str>,\n    app_handle: AppHandle<R>,\n    window: WebviewWindow<R>,\n    ws_manager: State<'_, Mutex<WebsocketManager>>,\n) -> Result<WebsocketConnection> {\n    let connection = app_handle.db().get_websocket_connection(connection_id)?;\n    let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?;\n    let environment_chain = app_handle.db().resolve_environments(\n        &unrendered_request.workspace_id,\n        unrendered_request.folder_id.as_deref(),\n        environment_id,\n    )?;\n    let (resolved_request, _auth_context_id) =\n        resolve_websocket_request(&window, &unrendered_request)?;\n    let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());\n    let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());\n    let request = render_websocket_request(\n        &resolved_request,\n        environment_chain,\n        &PluginTemplateCallback::new(\n            plugin_manager,\n            encryption_manager,\n            &window.plugin_context(),\n            RenderPurpose::Send,\n        ),\n        &RenderOptions { error_behavior: RenderErrorBehavior::Throw },\n    )\n    .await?;\n\n    let message = maybe_strip_json_comments(&request.message);\n\n    let mut ws_manager = ws_manager.lock().await;\n    ws_manager.send(&connection.id, Message::Text(message.clone().into())).await?;\n\n    app_handle.db().upsert_websocket_event(\n        &WebsocketEvent {\n            connection_id: connection.id.clone(),\n            request_id: request.id.clone(),\n            workspace_id: connection.workspace_id.clone(),\n            is_server: false,\n            message_type: WebsocketEventType::Text,\n            message: message.into(),\n            ..Default::default()\n        },\n        &UpdateSource::from_window_label(window.label()),\n    )?;\n\n    Ok(connection)\n}\n\n#[command]\npub async fn cmd_ws_close<R: Runtime>(\n    connection_id: &str,\n    app_handle: AppHandle<R>,\n    window: WebviewWindow<R>,\n    ws_manager: State<'_, Mutex<WebsocketManager>>,\n) -> Result<WebsocketConnection> {\n    let connection = {\n        let db = app_handle.db();\n        let connection = db.get_websocket_connection(connection_id)?;\n        db.upsert_websocket_connection(\n            &WebsocketConnection { state: WebsocketConnectionState::Closing, ..connection },\n            &UpdateSource::from_window_label(window.label()),\n        )?\n    };\n\n    let mut ws_manager = ws_manager.lock().await;\n    if let Err(e) = ws_manager.close(&connection.id).await {\n        warn!(\"Failed to close WebSocket connection: {e:?}\");\n    };\n\n    Ok(connection)\n}\n\n#[command]\npub async fn cmd_ws_connect<R: Runtime>(\n    request_id: &str,\n    environment_id: Option<&str>,\n    cookie_jar_id: Option<&str>,\n    app_handle: AppHandle<R>,\n    window: WebviewWindow<R>,\n    _plugin_manager: State<'_, PluginManager>,\n    ws_manager: State<'_, Mutex<WebsocketManager>>,\n) -> Result<WebsocketConnection> {\n    let unrendered_request = app_handle.db().get_websocket_request(request_id)?;\n    let environment_chain = app_handle.db().resolve_environments(\n        &unrendered_request.workspace_id,\n        unrendered_request.folder_id.as_deref(),\n        environment_id,\n    )?;\n    let workspace = app_handle.db().get_workspace(&unrendered_request.workspace_id)?;\n    let settings = app_handle.db().get_settings();\n    let (resolved_request, auth_context_id) =\n        resolve_websocket_request(&window, &unrendered_request)?;\n    let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());\n    let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());\n    let request = render_websocket_request(\n        &resolved_request,\n        environment_chain,\n        &PluginTemplateCallback::new(\n            plugin_manager.clone(),\n            encryption_manager.clone(),\n            &window.plugin_context(),\n            RenderPurpose::Send,\n        ),\n        &RenderOptions { error_behavior: RenderErrorBehavior::Throw },\n    )\n    .await?;\n\n    let connection = app_handle.db().upsert_websocket_connection(\n        &WebsocketConnection {\n            workspace_id: request.workspace_id.clone(),\n            request_id: request_id.to_string(),\n            ..Default::default()\n        },\n        &UpdateSource::from_window_label(window.label()),\n    )?;\n\n    let (mut url, url_parameters) = apply_path_placeholders(&request.url, &request.url_parameters);\n    if !url.starts_with(\"ws://\") && !url.starts_with(\"wss://\") {\n        url.insert_str(0, \"ws://\");\n    }\n\n    // Add URL parameters to URL\n    let mut url = match Url::parse(&url) {\n        Ok(url) => url,\n        Err(e) => {\n            return Ok(app_handle.db().upsert_websocket_connection(\n                &WebsocketConnection {\n                    error: Some(format!(\"Failed to parse URL {}\", e.to_string())),\n                    state: WebsocketConnectionState::Closed,\n                    ..connection\n                },\n                &UpdateSource::from_window_label(window.label()),\n            )?);\n        }\n    };\n\n    let mut headers = HeaderMap::new();\n\n    for h in request.headers.clone() {\n        if h.name.is_empty() && h.value.is_empty() {\n            continue;\n        }\n\n        if !h.enabled {\n            continue;\n        }\n\n        headers.insert(\n            http::HeaderName::from_str(&h.name).unwrap(),\n            HeaderValue::from_str(&h.value).unwrap(),\n        );\n    }\n\n    match request.authentication_type {\n        None => {\n            // No authentication found. Not even inherited\n        }\n        Some(authentication_type) if authentication_type == \"none\" => {\n            // Explicitly no authentication\n        }\n        Some(authentication_type) => {\n            let auth = request.authentication.clone();\n            let plugin_req = CallHttpAuthenticationRequest {\n                context_id: format!(\"{:x}\", md5::compute(auth_context_id)),\n                values: serde_json::from_value(serde_json::to_value(&auth).unwrap()).unwrap(),\n                method: \"POST\".to_string(),\n                url: request.url.clone(),\n                headers: request\n                    .headers\n                    .clone()\n                    .into_iter()\n                    .map(|h| HttpHeader { name: h.name, value: h.value })\n                    .collect(),\n            };\n            let plugin_result = plugin_manager\n                .call_http_authentication(\n                    &window.plugin_context(),\n                    &authentication_type,\n                    plugin_req,\n                )\n                .await?;\n            for header in plugin_result.set_headers.unwrap_or_default() {\n                match (\n                    http::HeaderName::from_str(&header.name),\n                    HeaderValue::from_str(&header.value),\n                ) {\n                    (Ok(name), Ok(value)) => {\n                        headers.insert(name, value);\n                    }\n                    _ => continue,\n                };\n            }\n            if let Some(params) = plugin_result.set_query_parameters {\n                let mut query_pairs = url.query_pairs_mut();\n                for p in params {\n                    query_pairs.append_pair(&p.name, &p.value);\n                }\n            }\n        }\n    }\n\n    // Add cookies to WS HTTP Upgrade\n    if let Some(id) = cookie_jar_id {\n        let cookie_jar = app_handle.db().get_cookie_jar(&id)?;\n        let store = CookieStore::from_cookies(cookie_jar.cookies);\n\n        // Convert WS URL -> HTTP URL because our cookie store matches based on\n        // Path/HttpOnly/Secure attributes even though WS upgrades are HTTP requests\n        let http_url = convert_ws_url_to_http(&url);\n        if let Some(cookie_header_value) = store.get_cookie_header(&http_url) {\n            debug!(\"Inserting cookies into WS upgrade to {}: {}\", url, cookie_header_value);\n            headers.insert(\n                http::HeaderName::from_static(\"cookie\"),\n                HeaderValue::from_str(&cookie_header_value).unwrap(),\n            );\n        }\n    }\n\n    let (receive_tx, mut receive_rx) = mpsc::channel::<Message>(128);\n    let mut ws_manager = ws_manager.lock().await;\n\n    {\n        let valid_query_pairs = url_parameters\n            .into_iter()\n            .filter(|p| p.enabled && !p.name.is_empty())\n            .collect::<Vec<_>>();\n        // NOTE: Only mutate query pairs if there are any, or it will append an empty `?` to the URL\n        if !valid_query_pairs.is_empty() {\n            let mut query_pairs = url.query_pairs_mut();\n            for p in valid_query_pairs {\n                query_pairs.append_pair(p.name.as_str(), p.value.as_str());\n            }\n        }\n    }\n\n    let client_cert = find_client_certificate(url.as_str(), &settings.client_certificates);\n\n    let response = match ws_manager\n        .connect(\n            &connection.id,\n            url.as_str(),\n            headers,\n            receive_tx,\n            workspace.setting_validate_certificates,\n            client_cert,\n        )\n        .await\n    {\n        Ok(r) => r,\n        Err(e) => {\n            return Ok(app_handle.db().upsert_websocket_connection(\n                &WebsocketConnection {\n                    error: Some(e.to_string()),\n                    state: WebsocketConnectionState::Closed,\n                    ..connection\n                },\n                &UpdateSource::from_window_label(window.label()),\n            )?);\n        }\n    };\n\n    app_handle.db().upsert_websocket_event(\n        &WebsocketEvent {\n            connection_id: connection.id.clone(),\n            request_id: request.id.clone(),\n            workspace_id: connection.workspace_id.clone(),\n            is_server: false,\n            message_type: WebsocketEventType::Open,\n            ..Default::default()\n        },\n        &UpdateSource::from_window_label(window.label()),\n    )?;\n\n    let response_headers = response\n        .headers()\n        .into_iter()\n        .map(|(name, value)| HttpResponseHeader {\n            name: name.to_string(),\n            value: value.to_str().unwrap().to_string(),\n        })\n        .collect::<Vec<HttpResponseHeader>>();\n\n    let connection = app_handle.db().upsert_websocket_connection(\n        &WebsocketConnection {\n            state: WebsocketConnectionState::Connected,\n            headers: response_headers,\n            status: response.status().as_u16() as i32,\n            url: request.url.clone(),\n            ..connection\n        },\n        &UpdateSource::from_window_label(window.label()),\n    )?;\n\n    {\n        let connection_id = connection.id.clone();\n        let request_id = request.id.to_string();\n        let workspace_id = request.workspace_id.clone();\n        let connection = connection.clone();\n        let window_label = window.label().to_string();\n        let mut has_written_close = false;\n        tokio::spawn(async move {\n            while let Some(message) = receive_rx.recv().await {\n                if let Message::Close(_) = message {\n                    has_written_close = true;\n                }\n\n                app_handle\n                    .db()\n                    .upsert_websocket_event(\n                        &WebsocketEvent {\n                            connection_id: connection_id.clone(),\n                            request_id: request_id.clone(),\n                            workspace_id: workspace_id.clone(),\n                            is_server: true,\n                            message_type: match message {\n                                Message::Text(_) => WebsocketEventType::Text,\n                                Message::Binary(_) => WebsocketEventType::Binary,\n                                Message::Ping(_) => WebsocketEventType::Ping,\n                                Message::Pong(_) => WebsocketEventType::Pong,\n                                Message::Close(_) => WebsocketEventType::Close,\n                                // Raw frame will never happen during a read\n                                Message::Frame(_) => WebsocketEventType::Frame,\n                            },\n                            message: message.into_data().into(),\n                            ..Default::default()\n                        },\n                        &UpdateSource::from_window_label(&window_label),\n                    )\n                    .unwrap();\n            }\n            info!(\"Websocket connection closed\");\n            if !has_written_close {\n                app_handle\n                    .db()\n                    .upsert_websocket_event(\n                        &WebsocketEvent {\n                            connection_id: connection_id.clone(),\n                            request_id: request_id.clone(),\n                            workspace_id: workspace_id.clone(),\n                            is_server: true,\n                            message_type: WebsocketEventType::Close,\n                            ..Default::default()\n                        },\n                        &UpdateSource::from_window_label(&window_label),\n                    )\n                    .unwrap();\n            }\n            app_handle\n                .db()\n                .upsert_websocket_connection(\n                    &WebsocketConnection {\n                        workspace_id: request.workspace_id.clone(),\n                        request_id: request_id.to_string(),\n                        state: WebsocketConnectionState::Closed,\n                        ..connection\n                    },\n                    &UpdateSource::from_window_label(&window_label),\n                )\n                .unwrap();\n        });\n    }\n\n    Ok(connection)\n}\n\n/// Resolve inherited authentication and headers for a websocket request\nfn resolve_websocket_request<R: Runtime>(\n    window: &WebviewWindow<R>,\n    request: &WebsocketRequest,\n) -> Result<(WebsocketRequest, String)> {\n    let mut new_request = request.clone();\n\n    let (authentication_type, authentication, authentication_context_id) =\n        window.db().resolve_auth_for_websocket_request(request)?;\n    new_request.authentication_type = authentication_type;\n    new_request.authentication = authentication;\n\n    let headers = window.db().resolve_headers_for_websocket_request(request)?;\n    new_request.headers = headers;\n\n    Ok((new_request, authentication_context_id))\n}\n\n/// Convert WS URL to HTTP URL for cookie filtering\n/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply\nfn convert_ws_url_to_http(ws_url: &Url) -> Url {\n    let mut http_url = ws_url.clone();\n\n    match ws_url.scheme() {\n        \"ws\" => {\n            http_url.set_scheme(\"http\").expect(\"Failed to set http scheme\");\n        }\n        \"wss\" => {\n            http_url.set_scheme(\"https\").expect(\"Failed to set https scheme\");\n        }\n        _ => {\n            // Already HTTP/HTTPS, no conversion needed\n        }\n    }\n\n    http_url\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/tauri.conf.json",
    "content": "{\n  \"productName\": \"Yaak\",\n  \"version\": \"0.0.0\",\n  \"identifier\": \"app.yaak.desktop\",\n  \"build\": {\n    \"beforeBuildCommand\": \"npm run tauri-before-build\",\n    \"beforeDevCommand\": \"npm run tauri-before-dev\",\n    \"devUrl\": \"http://localhost:1420\",\n    \"frontendDist\": \"../../dist\"\n  },\n  \"app\": {\n    \"withGlobalTauri\": false,\n    \"security\": {\n      \"assetProtocol\": {\n        \"enable\": true,\n        \"scope\": {\n          \"allow\": [\"$APPDATA/responses/*\", \"$RESOURCE/static/*\"]\n        }\n      }\n    }\n  },\n  \"plugins\": {\n    \"deep-link\": {\n      \"desktop\": {\n        \"schemes\": [\"yaak\"]\n      }\n    }\n  },\n  \"bundle\": {\n    \"icon\": [\n      \"icons/release/32x32.png\",\n      \"icons/release/128x128.png\",\n      \"icons/release/128x128@2x.png\",\n      \"icons/release/icon.icns\",\n      \"icons/release/icon.ico\"\n    ],\n    \"resources\": [\n      \"static\",\n      \"vendored/protoc/include\",\n      \"vendored/plugins\",\n      \"vendored/plugin-runtime\",\n      \"vendored/node/yaaknode*\",\n      \"vendored/protoc/yaakprotoc*\"\n    ]\n  }\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/tauri.development.conf.json",
    "content": "{\n  \"productName\": \"Daak\",\n  \"identifier\": \"app.yaak.desktop.dev\",\n  \"bundle\": {\n    \"icon\": [\n      \"icons/dev/32x32.png\",\n      \"icons/dev/128x128.png\",\n      \"icons/dev/128x128@2x.png\",\n      \"icons/dev/icon.icns\",\n      \"icons/dev/icon.ico\"\n    ]\n  }\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/tauri.linux.conf.json",
    "content": "{\n  \"productName\": \"yaak\"\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/tauri.release.conf.json",
    "content": "{\n  \"build\": {\n    \"features\": [\"updater\", \"license\"]\n  },\n  \"app\": {\n    \"security\": {\n      \"capabilities\": [\n        \"default\",\n        {\n          \"identifier\": \"release\",\n          \"windows\": [\"*\"],\n          \"permissions\": [\"yaak-license:default\"]\n        }\n      ]\n    }\n  },\n  \"plugins\": {\n    \"updater\": {\n      \"endpoints\": [\"https://update.yaak.app/check/{{target}}/{{arch}}/{{current_version}}\"],\n      \"pubkey\": \"dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEVGRkFGMjQxRUNEOTQ3MzAKUldRd1I5bnNRZkw2NzRtMnRlWTN3R24xYUR3aGRsUjJzWGwvdHdEcGljb3ZJMUNlMjFsaHlqVU4K\"\n    }\n  },\n  \"bundle\": {\n    \"publisher\": \"Yaak\",\n    \"license\": \"MIT\",\n    \"copyright\": \"Yaak\",\n    \"homepage\": \"https://yaak.app\",\n    \"active\": true,\n    \"category\": \"DeveloperTool\",\n    \"createUpdaterArtifacts\": true,\n    \"longDescription\": \"A cross-platform desktop app for interacting with REST, GraphQL, and gRPC\",\n    \"shortDescription\": \"Play with APIs, intuitively\",\n    \"targets\": [\"app\", \"appimage\", \"deb\", \"dmg\", \"nsis\", \"rpm\"],\n    \"macOS\": {\n      \"minimumSystemVersion\": \"13.0\",\n      \"exceptionDomain\": \"\",\n      \"entitlements\": \"macos/entitlements.plist\",\n      \"frameworks\": []\n    },\n    \"windows\": {\n      \"signCommand\": \"trusted-signing-cli -e https://eus.codesigning.azure.net/ -a Yaak -c yaakapp %1\"\n    },\n    \"linux\": {\n      \"deb\": {\n        \"desktopTemplate\": \"./template.desktop\",\n        \"files\": {\n          \"/usr/share/metainfo/app.yaak.Yaak.metainfo.xml\": \"../../flatpak/app.yaak.Yaak.metainfo.xml\"\n        }\n      },\n      \"rpm\": {\n        \"desktopTemplate\": \"./template.desktop\",\n        \"files\": {\n          \"/usr/share/metainfo/app.yaak.Yaak.metainfo.xml\": \"../../flatpak/app.yaak.Yaak.metainfo.xml\"\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "crates-tauri/yaak-app/template.desktop",
    "content": "[Desktop Entry]\nCategories={{categories}}\nComment={{comment}}\nExec={{exec}}\nIcon={{icon}}\nName={{name}}\nStartupWMClass={{exec}}\nTerminal=false\nType=Application\n"
  },
  {
    "path": "crates-tauri/yaak-fonts/Cargo.toml",
    "content": "[package]\nname = \"yaak-fonts\"\nlinks = \"yaak-fonts\"\nversion = \"0.1.0\"\nedition = \"2021\"\npublish = false\n\n[dependencies]\nfont-loader = \"0.11.0\"\ntauri = { workspace = true }\nts-rs = { workspace = true }\nserde = \"1.0\"\nthiserror = { workspace = true }\n\n[build-dependencies]\ntauri-plugin = { workspace = true, features = [\"build\"] }\n"
  },
  {
    "path": "crates-tauri/yaak-fonts/bindings/gen_fonts.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type Fonts = { editorFonts: Array<string>, uiFonts: Array<string>, };\n"
  },
  {
    "path": "crates-tauri/yaak-fonts/build.rs",
    "content": "const COMMANDS: &[&str] = &[\"list\"];\n\nfn main() {\n    tauri_plugin::Builder::new(COMMANDS).build();\n}\n"
  },
  {
    "path": "crates-tauri/yaak-fonts/index.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { Fonts } from \"./bindings/gen_fonts\";\n\nexport async function listFonts() {\n  return invoke<Fonts>(\"plugin:yaak-fonts|list\", {});\n}\n\nexport function useFonts() {\n  return useQuery({\n    queryKey: [\"list_fonts\"],\n    queryFn: () => listFonts(),\n  });\n}\n"
  },
  {
    "path": "crates-tauri/yaak-fonts/package.json",
    "content": "{\n  \"name\": \"@yaakapp-internal/fonts\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"index.ts\"\n}\n"
  },
  {
    "path": "crates-tauri/yaak-fonts/permissions/default.toml",
    "content": "[default]\ndescription = \"Default permissions for the plugin\"\npermissions = [\"allow-list\"]\n"
  },
  {
    "path": "crates-tauri/yaak-fonts/src/commands.rs",
    "content": "use crate::Result;\nuse font_loader::system_fonts;\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashSet;\nuse tauri::command;\nuse ts_rs::TS;\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize, TS, PartialEq)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_fonts.ts\")]\npub struct Fonts {\n    pub editor_fonts: Vec<String>,\n    pub ui_fonts: Vec<String>,\n}\n\n#[command]\npub(crate) async fn list() -> Result<Fonts> {\n    let mut ui_fonts = HashSet::new();\n    let mut editor_fonts = HashSet::new();\n\n    let mut property = system_fonts::FontPropertyBuilder::new().monospace().build();\n    for font in &system_fonts::query_specific(&mut property) {\n        editor_fonts.insert(font.to_string());\n    }\n    for font in &system_fonts::query_all() {\n        if !editor_fonts.contains(font) {\n            ui_fonts.insert(font.to_string());\n        }\n    }\n\n    let mut ui_fonts: Vec<String> = ui_fonts.into_iter().collect();\n    let mut editor_fonts: Vec<String> = editor_fonts.into_iter().collect();\n\n    ui_fonts.sort();\n    editor_fonts.sort();\n\n    Ok(Fonts { ui_fonts, editor_fonts })\n}\n"
  },
  {
    "path": "crates-tauri/yaak-fonts/src/error.rs",
    "content": "use serde::{ser::Serializer, Serialize};\n\n#[derive(Debug, thiserror::Error)]\npub enum Error {}\n\nimpl Serialize for Error {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        serializer.serialize_str(self.to_string().as_ref())\n    }\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates-tauri/yaak-fonts/src/lib.rs",
    "content": "use tauri::{\n    generate_handler,\n    plugin::{Builder, TauriPlugin},\n    Runtime,\n};\n\nmod commands;\nmod error;\n\nuse crate::commands::list;\npub use error::{Error, Result};\n\npub fn init<R: Runtime>() -> TauriPlugin<R> {\n    Builder::new(\"yaak-fonts\").invoke_handler(generate_handler![list]).build()\n}\n"
  },
  {
    "path": "crates-tauri/yaak-license/Cargo.toml",
    "content": "[package]\nname = \"yaak-license\"\nlinks = \"yaak-license\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nchrono = \"0.4.38\"\nlog = { workspace = true }\nreqwest = { workspace = true, features = [\"json\"] }\nserde = { workspace = true, features = [\"derive\"] }\nserde_json = { workspace = true }\ntauri = { workspace = true }\nthiserror = { workspace = true }\nts-rs = { workspace = true }\nyaak-common = { workspace = true }\nyaak-models = { workspace = true }\nyaak-api = { workspace = true }\n\n[build-dependencies]\ntauri-plugin = { workspace = true, features = [\"build\"] }\n"
  },
  {
    "path": "crates-tauri/yaak-license/bindings/gen_models.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type CheckActivationRequestPayload = { appVersion: string, appPlatform: string, };\n"
  },
  {
    "path": "crates-tauri/yaak-license/bindings/license.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type APIErrorResponsePayload = { error: string, message: string, };\n\nexport type ActivateLicenseRequestPayload = { licenseKey: string, appVersion: string, appPlatform: string, };\n\nexport type ActivateLicenseResponsePayload = { activationId: string, };\n\nexport type DeactivateLicenseRequestPayload = { appVersion: string, appPlatform: string, };\n\nexport type LicenseCheckStatus = { \"status\": \"personal_use\", \"data\": { trial_ended: string, } } | { \"status\": \"trialing\", \"data\": { end: string, } } | { \"status\": \"error\", \"data\": { message: string, code: string, } } | { \"status\": \"active\", \"data\": { periodEnd: string, cancelAt: string | null, } } | { \"status\": \"inactive\", \"data\": { status: string, } } | { \"status\": \"expired\", \"data\": { changes: number, changesUrl: string | null, billingUrl: string, periodEnd: string, } } | { \"status\": \"past_due\", \"data\": { billingUrl: string, periodEnd: string, } };\n"
  },
  {
    "path": "crates-tauri/yaak-license/bindings/models.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type CheckActivationRequestPayload = { activationId: string, };\n"
  },
  {
    "path": "crates-tauri/yaak-license/build.rs",
    "content": "const COMMANDS: &[&str] = &[\"activate\", \"deactivate\", \"check\"];\n\nfn main() {\n    tauri_plugin::Builder::new(COMMANDS).build();\n}\n"
  },
  {
    "path": "crates-tauri/yaak-license/index.ts",
    "content": "import { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { listen } from \"@tauri-apps/api/event\";\nimport { appInfo } from \"@yaakapp/app/lib/appInfo\";\nimport { useEffect } from \"react\";\nimport { LicenseCheckStatus } from \"./bindings/license\";\n\nexport * from \"./bindings/license\";\n\nconst CHECK_QUERY_KEY = [\"license.check\"];\n\nexport function useLicense() {\n  const queryClient = useQueryClient();\n  const activate = useMutation<void, string, { licenseKey: string }>({\n    mutationKey: [\"license.activate\"],\n    mutationFn: (payload) => invoke(\"plugin:yaak-license|activate\", payload),\n    onSuccess: () => queryClient.invalidateQueries({ queryKey: CHECK_QUERY_KEY }),\n  });\n\n  const deactivate = useMutation<void, string, void>({\n    mutationKey: [\"license.deactivate\"],\n    mutationFn: () => invoke(\"plugin:yaak-license|deactivate\"),\n    onSuccess: () => queryClient.invalidateQueries({ queryKey: CHECK_QUERY_KEY }),\n  });\n\n  // Check the license again after a license is activated\n  useEffect(() => {\n    const unlisten = listen(\"license-activated\", async () => {\n      await queryClient.invalidateQueries({ queryKey: CHECK_QUERY_KEY });\n    });\n    return () => {\n      void unlisten.then((fn) => fn());\n    };\n  }, []);\n\n  const check = useQuery<LicenseCheckStatus | null, string>({\n    refetchInterval: 1000 * 60 * 60 * 12, // Refetch every 12 hours\n    refetchOnWindowFocus: false,\n    queryKey: CHECK_QUERY_KEY,\n    queryFn: async () => {\n      if (!appInfo.featureLicense) {\n        return null;\n      }\n      return invoke<LicenseCheckStatus>(\"plugin:yaak-license|check\");\n    },\n  });\n\n  return {\n    activate,\n    deactivate,\n    check,\n  } as const;\n}\n"
  },
  {
    "path": "crates-tauri/yaak-license/package.json",
    "content": "{\n  \"name\": \"@yaakapp-internal/license\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"index.ts\"\n}\n"
  },
  {
    "path": "crates-tauri/yaak-license/permissions/default.toml",
    "content": "[default]\ndescription = \"Default permissions for the plugin\"\npermissions = [\"allow-check\", \"allow-activate\", \"allow-deactivate\"]\n"
  },
  {
    "path": "crates-tauri/yaak-license/src/commands.rs",
    "content": "use crate::error::Result;\nuse crate::{LicenseCheckStatus, activate_license, check_license, deactivate_license};\nuse tauri::{Runtime, WebviewWindow, command};\n\n#[command]\npub async fn check<R: Runtime>(window: WebviewWindow<R>) -> Result<LicenseCheckStatus> {\n    check_license(&window).await\n}\n\n#[command]\npub async fn activate<R: Runtime>(license_key: &str, window: WebviewWindow<R>) -> Result<()> {\n    activate_license(&window, license_key).await\n}\n\n#[command]\npub async fn deactivate<R: Runtime>(window: WebviewWindow<R>) -> Result<()> {\n    deactivate_license(&window).await\n}\n"
  },
  {
    "path": "crates-tauri/yaak-license/src/error.rs",
    "content": "use serde::{Serialize, Serializer};\nuse thiserror::Error;\n\n#[derive(Error, Debug)]\npub enum Error {\n    #[error(\"Reqwest error: {0}\")]\n    APIError(#[from] reqwest::Error),\n\n    #[error(\"JSON error: {0}\")]\n    JsonError(#[from] serde_json::Error),\n\n    #[error(\"{message}\")]\n    ClientError { message: String, error: String },\n\n    #[error(transparent)]\n    ModelError(#[from] yaak_models::error::Error),\n\n    #[error(transparent)]\n    ApiError(#[from] yaak_api::Error),\n\n    #[error(\"Internal server error\")]\n    ServerError,\n}\n\nimpl Serialize for Error {\n    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>\n    where\n        S: Serializer,\n    {\n        serializer.serialize_str(self.to_string().as_ref())\n    }\n}\n\npub type Result<T> = std::result::Result<T, Error>;\n"
  },
  {
    "path": "crates-tauri/yaak-license/src/lib.rs",
    "content": "use tauri::{\n    Runtime, generate_handler,\n    plugin::{Builder, TauriPlugin},\n};\n\nmod commands;\npub mod error;\nmod license;\n\nuse crate::commands::{activate, check, deactivate};\npub use license::*;\n\npub fn init<R: Runtime>() -> TauriPlugin<R> {\n    Builder::new(\"yaak-license\")\n        .invoke_handler(generate_handler![check, activate, deactivate])\n        .build()\n}\n"
  },
  {
    "path": "crates-tauri/yaak-license/src/license.rs",
    "content": "use crate::error::Error::{ClientError, JsonError, ServerError};\nuse crate::error::Result;\nuse chrono::{DateTime, Utc};\nuse log::{info, warn};\nuse serde::{Deserialize, Serialize};\nuse std::ops::Add;\nuse std::time::Duration;\nuse tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};\nuse ts_rs::TS;\nuse yaak_api::{ApiClientKind, yaak_api_client};\nuse yaak_common::platform::get_os_str;\nuse yaak_models::db_context::DbContext;\nuse yaak_models::query_manager::QueryManager;\nuse yaak_models::util::UpdateSource;\n\n/// Extension trait for accessing the QueryManager from Tauri Manager types.\n/// This is needed temporarily until all crates are refactored to not use Tauri.\ntrait QueryManagerExt<'a, R> {\n    fn db(&'a self) -> DbContext<'a>;\n}\n\nimpl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {\n    fn db(&'a self) -> DbContext<'a> {\n        let qm = self.state::<QueryManager>();\n        qm.inner().connect()\n    }\n}\n\nconst KV_NAMESPACE: &str = \"license\";\nconst KV_ACTIVATION_ID_KEY: &str = \"activation_id\";\nconst TRIAL_SECONDS: u64 = 3600 * 24 * 30;\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"gen_models.ts\")]\npub struct CheckActivationRequestPayload {\n    pub app_version: String,\n    pub app_platform: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"license.ts\")]\npub struct ActivateLicenseRequestPayload {\n    pub license_key: String,\n    pub app_version: String,\n    pub app_platform: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"license.ts\")]\npub struct DeactivateLicenseRequestPayload {\n    pub app_version: String,\n    pub app_platform: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"license.ts\")]\npub struct ActivateLicenseResponsePayload {\n    pub activation_id: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"camelCase\")]\n#[ts(export, export_to = \"license.ts\")]\npub struct APIErrorResponsePayload {\n    pub error: String,\n    pub message: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, TS)]\n#[serde(rename_all = \"snake_case\", tag = \"status\", content = \"data\")]\n#[ts(export, export_to = \"license.ts\")]\npub enum LicenseCheckStatus {\n    // Local Types\n    PersonalUse {\n        trial_ended: DateTime<Utc>,\n    },\n    Trialing {\n        end: DateTime<Utc>,\n    },\n    Error {\n        message: String,\n        code: String,\n    },\n\n    // Server Types\n    Active {\n        #[serde(rename = \"periodEnd\")]\n        period_end: DateTime<Utc>,\n        #[serde(rename = \"cancelAt\")]\n        cancel_at: Option<DateTime<Utc>>,\n    },\n    Inactive {\n        status: String,\n    },\n    Expired {\n        changes: i32,\n        #[serde(rename = \"changesUrl\")]\n        changes_url: Option<String>,\n        #[serde(rename = \"billingUrl\")]\n        billing_url: String,\n        #[serde(rename = \"periodEnd\")]\n        period_end: DateTime<Utc>,\n    },\n    PastDue {\n        #[serde(rename = \"billingUrl\")]\n        billing_url: String,\n        #[serde(rename = \"periodEnd\")]\n        period_end: DateTime<Utc>,\n    },\n}\n\npub async fn activate_license<R: Runtime>(\n    window: &WebviewWindow<R>,\n    license_key: &str,\n) -> Result<()> {\n    info!(\"Activating license {}\", license_key);\n    let app_version = window.app_handle().package_info().version.to_string();\n    let client = yaak_api_client(ApiClientKind::App, &app_version)?;\n    let payload = ActivateLicenseRequestPayload {\n        license_key: license_key.to_string(),\n        app_platform: get_os_str().to_string(),\n        app_version,\n    };\n    let response = client.post(build_url(\"/licenses/activate\")).json(&payload).send().await?;\n\n    if response.status().is_client_error() {\n        let body: APIErrorResponsePayload = response.json().await?;\n        return Err(ClientError { message: body.message, error: body.error });\n    }\n\n    if response.status().is_server_error() {\n        return Err(ServerError);\n    }\n\n    let body: ActivateLicenseResponsePayload = response.json().await?;\n    window.app_handle().db().set_key_value_str(\n        KV_ACTIVATION_ID_KEY,\n        KV_NAMESPACE,\n        body.activation_id.as_str(),\n        &UpdateSource::from_window_label(window.label()),\n    );\n\n    if let Err(e) = window.emit(\"license-activated\", true) {\n        warn!(\"Failed to emit check-license event: {}\", e);\n    }\n\n    Ok(())\n}\n\npub async fn deactivate_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<()> {\n    info!(\"Deactivating activation\");\n    let app_handle = window.app_handle();\n    let activation_id = get_activation_id(app_handle).await;\n\n    let app_version = window.app_handle().package_info().version.to_string();\n    let client = yaak_api_client(ApiClientKind::App, &app_version)?;\n    let path = format!(\"/licenses/activations/{}/deactivate\", activation_id);\n    let payload =\n        DeactivateLicenseRequestPayload { app_platform: get_os_str().to_string(), app_version };\n    let response = client.post(build_url(&path)).json(&payload).send().await?;\n\n    if response.status().is_client_error() {\n        let body: APIErrorResponsePayload = response.json().await?;\n        return Err(ClientError { message: body.message, error: body.error });\n    }\n\n    if response.status().is_server_error() {\n        return Err(ServerError);\n    }\n\n    app_handle.db().delete_key_value(\n        KV_ACTIVATION_ID_KEY,\n        KV_NAMESPACE,\n        &UpdateSource::from_window_label(window.label()),\n    )?;\n\n    if let Err(e) = app_handle.emit(\"license-deactivated\", true) {\n        warn!(\"Failed to emit deactivate-license event: {}\", e);\n    }\n\n    Ok(())\n}\n\npub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<LicenseCheckStatus> {\n    let app_version = window.app_handle().package_info().version.to_string();\n    let payload =\n        CheckActivationRequestPayload { app_platform: get_os_str().to_string(), app_version };\n    let activation_id = get_activation_id(window.app_handle()).await;\n\n    let settings = window.db().get_settings();\n    let trial_end = settings.created_at.add(Duration::from_secs(TRIAL_SECONDS)).and_utc();\n\n    let has_activation_id = !activation_id.is_empty();\n    let trial_period_active = Utc::now() < trial_end;\n\n    match (has_activation_id, trial_period_active) {\n        (false, true) => Ok(LicenseCheckStatus::Trialing { end: trial_end }),\n        (false, false) => Ok(LicenseCheckStatus::PersonalUse { trial_ended: trial_end }),\n        (true, _) => {\n            info!(\"Checking license activation\");\n            // A license has been activated, so let's check the license server\n            let client = yaak_api_client(ApiClientKind::App, &payload.app_version)?;\n            let path = format!(\"/licenses/activations/{activation_id}/check-v2\");\n            let response = client.post(build_url(&path)).json(&payload).send().await?;\n\n            if response.status().is_client_error() {\n                let body: APIErrorResponsePayload = response.json().await?;\n                return Err(ClientError { message: body.message, error: body.error });\n            }\n\n            if response.status().is_server_error() {\n                warn!(\"Failed to check license {}\", response.status());\n                return Err(ServerError);\n            }\n\n            let body_text = response.text().await?;\n            match serde_json::from_str::<LicenseCheckStatus>(&body_text) {\n                Ok(b) => Ok(b),\n                Err(e) => {\n                    warn!(\"Failed to decode server response: {} {:?}\", body_text, e);\n                    Err(JsonError(e))\n                }\n            }\n        }\n    }\n}\n\nfn build_url(path: &str) -> String {\n    if is_dev() {\n        format!(\"http://localhost:9444{path}\")\n    } else {\n        format!(\"https://license.yaak.app{path}\")\n    }\n}\n\npub async fn get_activation_id<R: Runtime>(app_handle: &AppHandle<R>) -> String {\n    app_handle.db().get_key_value_str(KV_ACTIVATION_ID_KEY, KV_NAMESPACE, \"\")\n}\n"
  },
  {
    "path": "crates-tauri/yaak-mac-window/Cargo.toml",
    "content": "[package]\nname = \"yaak-mac-window\"\nlinks = \"yaak-mac-window\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[lints.rust]\nunexpected_cfgs = { level = \"warn\", check-cfg = ['cfg(feature, values(\"cargo-clippy\"))'] }\n\n[build-dependencies]\ntauri-plugin = { workspace = true, features = [\"build\"] }\n\n[target.'cfg(target_os = \"macos\")'.dependencies]\ncocoa = \"0.26.0\"\nlog = { workspace = true }\nobjc = \"0.2.7\"\nrand = \"0.9.0\"\ncsscolorparser = \"0.7.2\"\n\n[dependencies]\ntauri = { workspace = true }\n"
  },
  {
    "path": "crates-tauri/yaak-mac-window/build.rs",
    "content": "const COMMANDS: &[&str] = &[\"set_title\", \"set_theme\"];\n\nfn main() {\n    tauri_plugin::Builder::new(COMMANDS).build();\n}\n"
  },
  {
    "path": "crates-tauri/yaak-mac-window/index.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\n\nexport function setWindowTitle(title: string) {\n  invoke(\"plugin:yaak-mac-window|set_title\", { title }).catch(console.error);\n}\n\nexport function setWindowTheme(bgColor: string) {\n  invoke(\"plugin:yaak-mac-window|set_theme\", { bgColor }).catch(console.error);\n}\n"
  },
  {
    "path": "crates-tauri/yaak-mac-window/package.json",
    "content": "{\n  \"name\": \"@yaakapp-internal/mac-window\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"index.ts\"\n}\n"
  },
  {
    "path": "crates-tauri/yaak-mac-window/permissions/default.toml",
    "content": "[default]\ndescription = \"Default permissions for the plugin\"\npermissions = [\"allow-set-title\", \"allow-set-theme\"]\n"
  },
  {
    "path": "crates-tauri/yaak-mac-window/src/commands.rs",
    "content": "use tauri::{Runtime, Window, command};\n\n#[command]\npub(crate) fn set_title<R: Runtime>(window: Window<R>, title: &str) {\n    #[cfg(target_os = \"macos\")]\n    {\n        crate::mac::update_window_title(window, title.to_string());\n    }\n\n    #[cfg(not(target_os = \"macos\"))]\n    {\n        let _ = window.set_title(title);\n    }\n}\n\n#[command]\n#[allow(unused)]\npub(crate) fn set_theme<R: Runtime>(window: Window<R>, bg_color: &str) {\n    #[cfg(target_os = \"macos\")]\n    {\n        use log::warn;\n        match csscolorparser::parse(bg_color.trim()) {\n            Ok(color) => {\n                crate::mac::update_window_theme(window, color);\n            }\n            Err(err) => {\n                warn!(\"Failed to parse background color '{}': {}\", bg_color, err)\n            }\n        }\n    }\n\n    #[cfg(not(target_os = \"macos\"))]\n    {\n        // Nothing yet for non-Mac platforms\n    }\n}\n"
  },
  {
    "path": "crates-tauri/yaak-mac-window/src/lib.rs",
    "content": "mod commands;\n\n#[cfg(target_os = \"macos\")]\nmod mac;\n\nuse crate::commands::{set_theme, set_title};\nuse std::sync::atomic::AtomicBool;\nuse tauri::{Manager, Runtime, generate_handler, plugin, plugin::TauriPlugin};\n\npub trait AppHandleMacWindowExt {\n    /// Sets whether to use the native titlebar\n    fn set_native_titlebar(&self, enable: bool);\n}\n\nimpl<R: Runtime> AppHandleMacWindowExt for tauri::AppHandle<R> {\n    fn set_native_titlebar(&self, enable: bool) {\n        self.state::<PluginState>()\n            .native_titlebar\n            .store(enable, std::sync::atomic::Ordering::Relaxed);\n    }\n}\n\npub(crate) struct PluginState {\n    native_titlebar: AtomicBool,\n}\n\npub fn init<R: Runtime>() -> TauriPlugin<R> {\n    let mut builder = plugin::Builder::new(\"yaak-mac-window\")\n        .setup(move |app, _| {\n            app.manage(PluginState { native_titlebar: AtomicBool::new(false) });\n            Ok(())\n        })\n        .invoke_handler(generate_handler![set_title, set_theme]);\n\n    #[cfg(target_os = \"macos\")]\n    {\n        builder = builder.on_window_ready(move |window| {\n            mac::setup_traffic_light_positioner(&window);\n        });\n    }\n\n    builder.build()\n}\n"
  },
  {
    "path": "crates-tauri/yaak-mac-window/src/mac.rs",
    "content": "#![allow(deprecated)]\nuse crate::PluginState;\nuse csscolorparser::Color;\nuse objc::{msg_send, sel, sel_impl};\nuse tauri::{Emitter, Manager, Runtime, State, Window};\n\nstruct UnsafeWindowHandle(*mut std::ffi::c_void);\n\nunsafe impl Send for UnsafeWindowHandle {}\n\nunsafe impl Sync for UnsafeWindowHandle {}\n\nconst WINDOW_CONTROL_PAD_X: f64 = 13.0;\nconst WINDOW_CONTROL_PAD_Y: f64 = 18.0;\n/// Extra pixels to add to the title bar height when the default title bar is\n/// already as tall as button_height + PAD_Y (i.e. macOS Tahoe 26+, where the\n/// default is 32px and 14 + 18 = 32). On pre-Tahoe this is unused because the\n/// default title bar is shorter than button_height + PAD_Y.\nconst TITLEBAR_EXTRA_HEIGHT: f64 = 4.0;\nconst MAIN_WINDOW_PREFIX: &str = \"main_\";\n\npub(crate) fn update_window_title<R: Runtime>(window: Window<R>, title: String) {\n    use cocoa::{appkit::NSWindow, base::nil, foundation::NSString};\n\n    let state: State<PluginState> = window.state();\n    let native_titlebar = state.native_titlebar.load(std::sync::atomic::Ordering::Relaxed);\n    unsafe {\n        let window_handle = UnsafeWindowHandle(window.ns_window().unwrap());\n\n        let window2 = window.clone();\n        let label = window.label().to_string();\n        let _ = window.run_on_main_thread(move || {\n            let win_title = NSString::alloc(nil).init_str(&title);\n            let handle = window_handle;\n            NSWindow::setTitle_(handle.0 as cocoa::base::id, win_title);\n            if !native_titlebar {\n                position_traffic_lights(\n                    UnsafeWindowHandle(\n                        window2.ns_window().expect(\"Failed to create window handle\"),\n                    ),\n                    WINDOW_CONTROL_PAD_X,\n                    WINDOW_CONTROL_PAD_Y,\n                    label,\n                );\n            }\n        });\n    }\n}\n\npub(crate) fn update_window_theme<R: Runtime>(window: Window<R>, color: Color) {\n    use cocoa::appkit::{\n        NSAppearance, NSAppearanceNameVibrantDark, NSAppearanceNameVibrantLight, NSWindow,\n    };\n\n    let brightness = (color.r as f64 + color.g as f64 + color.b as f64) / 3.0;\n    let label = window.label().to_string();\n    let state: State<PluginState> = window.state();\n    let native_titlebar = state.native_titlebar.load(std::sync::atomic::Ordering::Relaxed);\n\n    unsafe {\n        let window_handle = UnsafeWindowHandle(window.ns_window().unwrap());\n        let window2 = window.clone();\n        let _ = window.run_on_main_thread(move || {\n            let handle = window_handle;\n\n            let selected_appearance = if brightness >= 0.5 {\n                NSAppearance(NSAppearanceNameVibrantLight)\n            } else {\n                NSAppearance(NSAppearanceNameVibrantDark)\n            };\n\n            NSWindow::setAppearance(handle.0 as cocoa::base::id, selected_appearance);\n            if !native_titlebar {\n                position_traffic_lights(\n                    UnsafeWindowHandle(\n                        window2.ns_window().expect(\"Failed to create window handle\"),\n                    ),\n                    WINDOW_CONTROL_PAD_X,\n                    WINDOW_CONTROL_PAD_Y,\n                    label,\n                );\n            }\n        });\n    }\n}\n\nfn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64, label: String) {\n    if !label.starts_with(MAIN_WINDOW_PREFIX) {\n        return;\n    }\n\n    use cocoa::appkit::{NSView, NSWindow, NSWindowButton};\n    use cocoa::foundation::NSRect;\n\n    let ns_window = ns_window_handle.0 as cocoa::base::id;\n    #[allow(unexpected_cfgs)]\n    unsafe {\n        let close = ns_window.standardWindowButton_(NSWindowButton::NSWindowCloseButton);\n        let miniaturize =\n            ns_window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);\n        let zoom = ns_window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);\n\n        let close_rect: NSRect = msg_send![close, frame];\n        let button_height = close_rect.size.height;\n\n        let title_bar_container_view = close.superview().superview();\n\n        // Capture the OS default title bar height on the first call, before\n        // we've modified it. This avoids the height growing on repeated calls.\n        use std::sync::OnceLock;\n        static DEFAULT_TITLEBAR_HEIGHT: OnceLock<f64> = OnceLock::new();\n        let default_height =\n            *DEFAULT_TITLEBAR_HEIGHT.get_or_init(|| NSView::frame(title_bar_container_view).size.height);\n\n        // On pre-Tahoe, button_height + y is larger than the default title bar\n        // height, so the resize works as before. On Tahoe (26+), the default is\n        // already 32px and button_height + y = 32, so nothing changes. In that\n        // case, add TITLEBAR_EXTRA_HEIGHT extra pixels to push the buttons down.\n        let desired = button_height + y;\n        let title_bar_frame_height = if desired > default_height {\n            desired\n        } else {\n            default_height + TITLEBAR_EXTRA_HEIGHT\n        };\n\n        let mut title_bar_rect = NSView::frame(title_bar_container_view);\n        title_bar_rect.size.height = title_bar_frame_height;\n        title_bar_rect.origin.y = NSView::frame(ns_window).size.height - title_bar_frame_height;\n        let _: () = msg_send![title_bar_container_view, setFrame: title_bar_rect];\n\n        let window_buttons = vec![close, miniaturize, zoom];\n        let space_between = NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;\n\n        for (i, button) in window_buttons.into_iter().enumerate() {\n            let mut rect: NSRect = NSView::frame(button);\n            rect.origin.x = x + (i as f64 * space_between);\n            button.setFrameOrigin(rect.origin);\n        }\n    }\n}\n\n#[derive(Debug)]\nstruct WindowState<R: Runtime> {\n    window: Window<R>,\n}\n\npub fn setup_traffic_light_positioner<R: Runtime>(window: &Window<R>) {\n    use cocoa::appkit::NSWindow;\n    use cocoa::base::{BOOL, id};\n    use cocoa::delegate;\n    use cocoa::foundation::NSUInteger;\n    use objc::runtime::{Object, Sel};\n    use rand::Rng;\n    use rand::distr::Alphanumeric;\n    use std::ffi::c_void;\n\n    let state: State<PluginState> = window.state();\n    if state.native_titlebar.load(std::sync::atomic::Ordering::Relaxed) {\n        return;\n    }\n\n    position_traffic_lights(\n        UnsafeWindowHandle(window.ns_window().expect(\"Failed to create window handle\")),\n        WINDOW_CONTROL_PAD_X,\n        WINDOW_CONTROL_PAD_Y,\n        window.label().to_string(),\n    );\n\n    // Ensure they stay in place while resizing the window.\n    fn with_window_state<R: Runtime, F: FnOnce(&mut WindowState<R>) -> T, T>(\n        this: &Object,\n        func: F,\n    ) {\n        let ptr = unsafe {\n            let x: *mut c_void = *this.get_ivar(\"app_box\");\n            &mut *(x as *mut WindowState<R>)\n        };\n        func(ptr);\n    }\n\n    #[allow(unexpected_cfgs)]\n    unsafe {\n        let ns_win =\n            window.ns_window().expect(\"NS Window should exist to mount traffic light delegate.\")\n                as id;\n\n        let current_delegate: id = ns_win.delegate();\n\n        extern \"C\" fn on_window_should_close(this: &Object, _cmd: Sel, sender: id) -> BOOL {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                msg_send![super_del, windowShouldClose: sender]\n            }\n        }\n        extern \"C\" fn on_window_will_close(this: &Object, _cmd: Sel, notification: id) {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![super_del, windowWillClose: notification];\n            }\n        }\n        extern \"C\" fn on_window_did_resize<R: Runtime>(this: &Object, _cmd: Sel, notification: id) {\n            unsafe {\n                with_window_state(&*this, |state: &mut WindowState<R>| {\n                    let id = state\n                        .window\n                        .ns_window()\n                        .expect(\"NS window should exist on state to handle resize\")\n                        as id;\n\n                    position_traffic_lights(\n                        UnsafeWindowHandle(id as *mut c_void),\n                        WINDOW_CONTROL_PAD_X,\n                        WINDOW_CONTROL_PAD_Y,\n                        state.window.label().to_string(),\n                    );\n                });\n\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![super_del, windowDidResize: notification];\n            }\n        }\n        extern \"C\" fn on_window_did_move(this: &Object, _cmd: Sel, notification: id) {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![super_del, windowDidMove: notification];\n            }\n        }\n        extern \"C\" fn on_window_did_change_backing_properties(\n            this: &Object,\n            _cmd: Sel,\n            notification: id,\n        ) {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![super_del, windowDidChangeBackingProperties: notification];\n            }\n        }\n        extern \"C\" fn on_window_did_become_key(this: &Object, _cmd: Sel, notification: id) {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![super_del, windowDidBecomeKey: notification];\n            }\n        }\n        extern \"C\" fn on_window_did_resign_key(this: &Object, _cmd: Sel, notification: id) {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![super_del, windowDidResignKey: notification];\n            }\n        }\n        extern \"C\" fn on_dragging_entered(this: &Object, _cmd: Sel, notification: id) -> BOOL {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                msg_send![super_del, draggingEntered: notification]\n            }\n        }\n        extern \"C\" fn on_prepare_for_drag_operation(\n            this: &Object,\n            _cmd: Sel,\n            notification: id,\n        ) -> BOOL {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                msg_send![super_del, prepareForDragOperation: notification]\n            }\n        }\n        extern \"C\" fn on_perform_drag_operation(this: &Object, _cmd: Sel, sender: id) -> BOOL {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                msg_send![super_del, performDragOperation: sender]\n            }\n        }\n        extern \"C\" fn on_conclude_drag_operation(this: &Object, _cmd: Sel, notification: id) {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![super_del, concludeDragOperation: notification];\n            }\n        }\n        extern \"C\" fn on_dragging_exited(this: &Object, _cmd: Sel, notification: id) {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![super_del, draggingExited: notification];\n            }\n        }\n        extern \"C\" fn on_window_will_use_full_screen_presentation_options(\n            this: &Object,\n            _cmd: Sel,\n            window: id,\n            proposed_options: NSUInteger,\n        ) -> NSUInteger {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                msg_send![super_del, window: window willUseFullScreenPresentationOptions: proposed_options]\n            }\n        }\n        extern \"C\" fn on_window_did_enter_full_screen<R: Runtime>(\n            this: &Object,\n            _cmd: Sel,\n            notification: id,\n        ) {\n            unsafe {\n                with_window_state(&*this, |state: &mut WindowState<R>| {\n                    state.window.emit(\"did-enter-fullscreen\", ()).expect(\"Failed to emit event\");\n                });\n\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![super_del, windowDidEnterFullScreen: notification];\n            }\n        }\n        extern \"C\" fn on_window_will_enter_full_screen<R: Runtime>(\n            this: &Object,\n            _cmd: Sel,\n            notification: id,\n        ) {\n            unsafe {\n                with_window_state(&*this, |state: &mut WindowState<R>| {\n                    state.window.emit(\"will-enter-fullscreen\", ()).expect(\"Failed to emit event\");\n                });\n\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![super_del, windowWillEnterFullScreen: notification];\n            }\n        }\n        extern \"C\" fn on_window_did_exit_full_screen<R: Runtime>(\n            this: &Object,\n            _cmd: Sel,\n            notification: id,\n        ) {\n            unsafe {\n                with_window_state(&*this, |state: &mut WindowState<R>| {\n                    state.window.emit(\"did-exit-fullscreen\", ()).expect(\"Failed to emit event\");\n\n                    let id = state.window.ns_window().expect(\"Failed to emit event\") as id;\n                    position_traffic_lights(\n                        UnsafeWindowHandle(id as *mut c_void),\n                        WINDOW_CONTROL_PAD_X,\n                        WINDOW_CONTROL_PAD_Y,\n                        state.window.label().to_string(),\n                    );\n                });\n\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![super_del, windowDidExitFullScreen: notification];\n            }\n        }\n        extern \"C\" fn on_window_will_exit_full_screen<R: Runtime>(\n            this: &Object,\n            _cmd: Sel,\n            notification: id,\n        ) {\n            unsafe {\n                with_window_state(&*this, |state: &mut WindowState<R>| {\n                    state.window.emit(\"will-exit-fullscreen\", ()).expect(\"Failed to emit event\");\n                });\n\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![super_del, windowWillExitFullScreen: notification];\n            }\n        }\n        extern \"C\" fn on_window_did_fail_to_enter_full_screen(\n            this: &Object,\n            _cmd: Sel,\n            window: id,\n        ) {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![super_del, windowDidFailToEnterFullScreen: window];\n            }\n        }\n        extern \"C\" fn on_effective_appearance_did_change(\n            this: &Object,\n            _cmd: Sel,\n            notification: id,\n        ) {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![super_del, effectiveAppearanceDidChange: notification];\n            }\n        }\n        extern \"C\" fn on_effective_appearance_did_changed_on_main_thread(\n            this: &Object,\n            _cmd: Sel,\n            notification: id,\n        ) {\n            unsafe {\n                let super_del: id = *this.get_ivar(\"super_delegate\");\n                let _: () = msg_send![\n                    super_del,\n                    effectiveAppearanceDidChangedOnMainThread: notification\n                ];\n            }\n        }\n\n        // Are we de-allocing this properly? (I miss safe Rust :(  )\n        let window_label = window.label().to_string();\n\n        let app_state = WindowState { window: window.clone() };\n        let app_box = Box::into_raw(Box::new(app_state)) as *mut c_void;\n        let random_str: String =\n            rand::rng().sample_iter(&Alphanumeric).take(20).map(char::from).collect();\n\n        // We need to ensure we have a unique delegate name, otherwise we will panic while trying to create a duplicate\n        // delegate with the same name.\n        let delegate_name = format!(\"windowDelegate_{}_{}\", window_label, random_str);\n\n        ns_win.setDelegate_(delegate!(&delegate_name, {\n            window: id = ns_win,\n            app_box: *mut c_void = app_box,\n            toolbar: id = cocoa::base::nil,\n            super_delegate: id = current_delegate,\n            (windowShouldClose:) => on_window_should_close as extern \"C\" fn(&Object, Sel, id) -> BOOL,\n            (windowWillClose:) => on_window_will_close as extern \"C\" fn(&Object, Sel, id),\n            (windowDidResize:) => on_window_did_resize::<R> as extern \"C\" fn(&Object, Sel, id),\n            (windowDidMove:) => on_window_did_move as extern \"C\" fn(&Object, Sel, id),\n            (windowDidChangeBackingProperties:) => on_window_did_change_backing_properties as extern \"C\" fn(&Object, Sel, id),\n            (windowDidBecomeKey:) => on_window_did_become_key as extern \"C\" fn(&Object, Sel, id),\n            (windowDidResignKey:) => on_window_did_resign_key as extern \"C\" fn(&Object, Sel, id),\n            (draggingEntered:) => on_dragging_entered as extern \"C\" fn(&Object, Sel, id) -> BOOL,\n            (prepareForDragOperation:) => on_prepare_for_drag_operation as extern \"C\" fn(&Object, Sel, id) -> BOOL,\n            (performDragOperation:) => on_perform_drag_operation as extern \"C\" fn(&Object, Sel, id) -> BOOL,\n            (concludeDragOperation:) => on_conclude_drag_operation as extern \"C\" fn(&Object, Sel, id),\n            (draggingExited:) => on_dragging_exited as extern \"C\" fn(&Object, Sel, id),\n            (window:willUseFullScreenPresentationOptions:) => on_window_will_use_full_screen_presentation_options as extern \"C\" fn(&Object, Sel, id, NSUInteger) -> NSUInteger,\n            (windowDidEnterFullScreen:) => on_window_did_enter_full_screen::<R> as extern \"C\" fn(&Object, Sel, id),\n            (windowWillEnterFullScreen:) => on_window_will_enter_full_screen::<R> as extern \"C\" fn(&Object, Sel, id),\n            (windowDidExitFullScreen:) => on_window_did_exit_full_screen::<R> as extern \"C\" fn(&Object, Sel, id),\n            (windowWillExitFullScreen:) => on_window_will_exit_full_screen::<R> as extern \"C\" fn(&Object, Sel, id),\n            (windowDidFailToEnterFullScreen:) => on_window_did_fail_to_enter_full_screen as extern \"C\" fn(&Object, Sel, id),\n            (effectiveAppearanceDidChange:) => on_effective_appearance_did_change as extern \"C\" fn(&Object, Sel, id),\n            (effectiveAppearanceDidChangedOnMainThread:) => on_effective_appearance_did_changed_on_main_thread as extern \"C\" fn(&Object, Sel, id)\n        }))\n    }\n}\n"
  },
  {
    "path": "crates-tauri/yaak-tauri-utils/Cargo.toml",
    "content": "[package]\nname = \"yaak-tauri-utils\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\ntauri = { workspace = true }\nregex = \"1.11.0\"\n"
  },
  {
    "path": "crates-tauri/yaak-tauri-utils/src/lib.rs",
    "content": "pub mod window;\n"
  },
  {
    "path": "crates-tauri/yaak-tauri-utils/src/window.rs",
    "content": "use regex::Regex;\nuse tauri::{Runtime, WebviewWindow};\n\npub trait WorkspaceWindowTrait {\n    fn workspace_id(&self) -> Option<String>;\n    fn cookie_jar_id(&self) -> Option<String>;\n    fn environment_id(&self) -> Option<String>;\n    fn request_id(&self) -> Option<String>;\n}\n\nimpl<R: Runtime> WorkspaceWindowTrait for WebviewWindow<R> {\n    fn workspace_id(&self) -> Option<String> {\n        let url = self.url().unwrap();\n        let re = Regex::new(r\"/workspaces/(?<id>\\w+)\").unwrap();\n        match re.captures(url.as_str()) {\n            None => None,\n            Some(captures) => captures.name(\"id\").map(|c| c.as_str().to_string()),\n        }\n    }\n\n    fn cookie_jar_id(&self) -> Option<String> {\n        let url = self.url().unwrap();\n        let mut query_pairs = url.query_pairs();\n        query_pairs.find(|(k, _v)| k == \"cookie_jar_id\").map(|(_k, v)| v.to_string())\n    }\n\n    fn environment_id(&self) -> Option<String> {\n        let url = self.url().unwrap();\n        let mut query_pairs = url.query_pairs();\n        query_pairs.find(|(k, _v)| k == \"environment_id\").map(|(_k, v)| v.to_string())\n    }\n\n    fn request_id(&self) -> Option<String> {\n        let url = self.url().unwrap();\n        let mut query_pairs = url.query_pairs();\n        query_pairs.find(|(k, _v)| k == \"request_id\").map(|(_k, v)| v.to_string())\n    }\n}\n"
  },
  {
    "path": "flatpak/app.yaak.Yaak.metainfo.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<component type=\"desktop-application\">\n  <id>app.yaak.Yaak</id>\n\n  <name>Yaak</name>\n  <summary>An offline, Git friendly API Client</summary>\n\n  <developer id=\"app.yaak\">\n    <name>Yaak</name>\n  </developer>\n\n  <metadata_license>MIT</metadata_license>\n  <project_license>MIT</project_license>\n\n  <url type=\"homepage\">https://yaak.app</url>\n  <url type=\"bugtracker\">https://yaak.app/feedback</url>\n  <url type=\"contact\">https://yaak.app/feedback</url>\n  <url type=\"vcs-browser\">https://github.com/mountain-loop/yaak</url>\n\n  <description>\n    <p>\n      A fast, privacy-first API client for REST, GraphQL, SSE, WebSocket,\n      and gRPC — built with Tauri, Rust, and React.\n    </p>\n    <p>Features include:</p>\n    <ul>\n      <li>REST, GraphQL, SSE, WebSocket, and gRPC support</li>\n      <li>Local-only data, secrets encryption, and zero telemetry</li>\n      <li>Git-friendly plain-text project storage</li>\n      <li>Environment variables and template functions</li>\n      <li>Request chaining and dynamic values</li>\n      <li>OAuth 2.0, Bearer, Basic, API Key, AWS, JWT, and NTLM authentication</li>\n      <li>Import from cURL, Postman, Insomnia, and OpenAPI</li>\n      <li>Extensible plugin system</li>\n    </ul>\n  </description>\n\n  <launchable type=\"desktop-id\">app.yaak.Yaak.desktop</launchable>\n\n  <branding>\n    <color type=\"primary\" scheme_preference=\"light\">#8b32ff</color>\n    <color type=\"primary\" scheme_preference=\"dark\">#c293ff</color>\n  </branding>\n\n  <content_rating type=\"oars-1.1\" />\n\n  <screenshots>\n    <screenshot type=\"default\">\n      <caption>Crafting an API request</caption>\n      <image>https://assets.yaak.app/uploads/screenshot-BLG1w_2310x1326.png</image>\n    </screenshot>\n  </screenshots>\n\n  <releases>\n    <release version=\"2026.2.0\" date=\"2026-02-10\" />\n  </releases>\n</component>\n"
  },
  {
    "path": "flatpak/fix-lockfile.mjs",
    "content": "#!/usr/bin/env node\n\n// Adds missing `resolved` and `integrity` fields to npm package-lock.json.\n//\n// npm sometimes omits these fields for nested dependencies inside workspace\n// packages. This breaks offline installs and tools like flatpak-node-generator\n// that need explicit tarball URLs for every package.\n//\n// Based on https://github.com/grant-dennison/npm-package-lock-add-resolved\n// (MIT License, Copyright (c) 2024 Grant Dennison)\n\nimport { readFile, writeFile } from \"node:fs/promises\";\nimport { get } from \"node:https\";\n\nconst lockfilePath = process.argv[2] || \"package-lock.json\";\n\nfunction fetchJson(url) {\n  return new Promise((resolve, reject) => {\n    get(url, (res) => {\n      let data = \"\";\n      res.on(\"data\", (chunk) => {\n        data += chunk;\n      });\n      res.on(\"end\", () => {\n        if (res.statusCode === 200) {\n          resolve(JSON.parse(data));\n        } else {\n          reject(`${url} returned ${res.statusCode} ${res.statusMessage}`);\n        }\n      });\n      res.on(\"error\", reject);\n    }).on(\"error\", reject);\n  });\n}\n\nasync function fillResolved(name, p) {\n  const version = p.version.replace(/^.*@/, \"\");\n  console.log(`Retrieving metadata for ${name}@${version}`);\n  const metadataUrl = `https://registry.npmjs.com/${name}/${version}`;\n  const metadata = await fetchJson(metadataUrl);\n  p.resolved = metadata.dist.tarball;\n  p.integrity = metadata.dist.integrity;\n}\n\nlet changesMade = false;\n\nasync function fillAllResolved(packages) {\n  for (const packagePath in packages) {\n    if (packagePath === \"\") continue;\n    if (!packagePath.includes(\"node_modules/\")) continue;\n    const p = packages[packagePath];\n    if (p.link) continue;\n    if (!p.inBundle && !p.bundled && (!p.resolved || !p.integrity)) {\n      const packageName =\n        p.name ||\n        /^npm:(.+?)@.+$/.exec(p.version)?.[1] ||\n        packagePath.replace(/^.*node_modules\\/(?=.+?$)/, \"\");\n      await fillResolved(packageName, p);\n      changesMade = true;\n    }\n  }\n}\n\nconst oldContents = await readFile(lockfilePath, \"utf-8\");\nconst packageLock = JSON.parse(oldContents);\n\nawait fillAllResolved(packageLock.packages ?? []);\n\nif (changesMade) {\n  const newContents = JSON.stringify(packageLock, null, 2) + \"\\n\";\n  await writeFile(lockfilePath, newContents);\n  console.log(`Updated ${lockfilePath}`);\n} else {\n  console.log(\"No changes needed.\");\n}\n"
  },
  {
    "path": "flatpak/generate-sources.sh",
    "content": "#!/usr/bin/env bash\n#\n# Generate offline dependency source files for Flatpak builds.\n#\n# Prerequisites:\n#   pip install flatpak-node-generator tomlkit aiohttp\n#   Clone https://github.com/flatpak/flatpak-builder-tools (for cargo generator)\n#\n# Usage:\n#   ./flatpak/generate-sources.sh <flathub-repo-path>\n#   ./flatpak/generate-sources.sh ../flathub-repo\n\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nREPO_ROOT=\"$(cd \"$SCRIPT_DIR/..\" && pwd)\"\n\nif [ $# -lt 1 ]; then\n    echo \"Usage: $0 <flathub-repo-path>\"\n    echo \"Example: $0 ../flathub-repo\"\n    exit 1\nfi\n\nFLATHUB_REPO=\"$(cd \"$1\" && pwd)\"\n\npython3 \"$SCRIPT_DIR/flatpak-builder-tools/cargo/flatpak-cargo-generator.py\" \\\n  -o \"$FLATHUB_REPO/cargo-sources.json\" \"$REPO_ROOT/Cargo.lock\"\n\nTMPDIR=$(mktemp -d)\ntrap 'rm -rf \"$TMPDIR\"' EXIT\n\ncp \"$REPO_ROOT/package-lock.json\" \"$TMPDIR/package-lock.json\"\ncp \"$REPO_ROOT/package.json\" \"$TMPDIR/package.json\"\n\nnode \"$SCRIPT_DIR/fix-lockfile.mjs\" \"$TMPDIR/package-lock.json\"\n\nnode -e \"\n  const fs = require('fs');\n  const p = process.argv[1];\n  const d = JSON.parse(fs.readFileSync(p, 'utf-8'));\n  for (const [name, info] of Object.entries(d.packages || {})) {\n    if (name && (info.link || !info.resolved)) delete d.packages[name];\n  }\n  fs.writeFileSync(p, JSON.stringify(d, null, 2));\n\" \"$TMPDIR/package-lock.json\"\n\nflatpak-node-generator --no-requests-cache \\\n  -o \"$FLATHUB_REPO/node-sources.json\" npm \"$TMPDIR/package-lock.json\"\n"
  },
  {
    "path": "flatpak/update-manifest.sh",
    "content": "#!/usr/bin/env bash\n#\n# Update the Flathub repo for a new release.\n#\n# Usage:\n#   ./flatpak/update-manifest.sh <version-tag> <flathub-repo-path>\n#   ./flatpak/update-manifest.sh v2026.2.0 ../flathub-repo\n\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nREPO_ROOT=\"$(cd \"$SCRIPT_DIR/..\" && pwd)\"\n\nif [ $# -lt 2 ]; then\n    echo \"Usage: $0 <version-tag> <flathub-repo-path>\"\n    echo \"Example: $0 v2026.2.0 ../flathub-repo\"\n    exit 1\nfi\n\nVERSION_TAG=\"$1\"\nVERSION=\"${VERSION_TAG#v}\"\nFLATHUB_REPO=\"$(cd \"$2\" && pwd)\"\nMANIFEST=\"$FLATHUB_REPO/app.yaak.Yaak.yml\"\nMETAINFO=\"$SCRIPT_DIR/app.yaak.Yaak.metainfo.xml\"\n\nif [[ \"$VERSION\" == *-* ]]; then\n    echo \"Skipping pre-release version '$VERSION_TAG' (only stable releases are published to Flathub)\"\n    exit 0\nfi\n\nREPO=\"mountain-loop/yaak\"\nCOMMIT=$(git ls-remote \"https://github.com/$REPO.git\" \"refs/tags/$VERSION_TAG\" | cut -f1)\n\nif [ -z \"$COMMIT\" ]; then\n    echo \"Error: Could not resolve commit for tag $VERSION_TAG\"\n    exit 1\nfi\n\necho \"Tag: $VERSION_TAG\"\necho \"Commit: $COMMIT\"\n\n# Update git tag and commit in the manifest\nsed -i \"s|tag: v.*|tag: $VERSION_TAG|\" \"$MANIFEST\"\nsed -i \"s|commit: .*|commit: $COMMIT|\" \"$MANIFEST\"\necho \"Updated manifest tag and commit.\"\n\n# Regenerate offline dependency sources from the tagged lockfiles\nTMPDIR=$(mktemp -d)\ntrap 'rm -rf \"$TMPDIR\"' EXIT\n\necho \"Fetching lockfiles from $VERSION_TAG...\"\ncurl -fsSL \"https://raw.githubusercontent.com/$REPO/$VERSION_TAG/Cargo.lock\" -o \"$TMPDIR/Cargo.lock\"\ncurl -fsSL \"https://raw.githubusercontent.com/$REPO/$VERSION_TAG/package-lock.json\" -o \"$TMPDIR/package-lock.json\"\ncurl -fsSL \"https://raw.githubusercontent.com/$REPO/$VERSION_TAG/package.json\" -o \"$TMPDIR/package.json\"\n\necho \"Generating cargo-sources.json...\"\npython3 \"$SCRIPT_DIR/flatpak-builder-tools/cargo/flatpak-cargo-generator.py\" \\\n  -o \"$FLATHUB_REPO/cargo-sources.json\" \"$TMPDIR/Cargo.lock\"\n\necho \"Generating node-sources.json...\"\nnode \"$SCRIPT_DIR/fix-lockfile.mjs\" \"$TMPDIR/package-lock.json\"\n\nnode -e \"\n  const fs = require('fs');\n  const p = process.argv[1];\n  const d = JSON.parse(fs.readFileSync(p, 'utf-8'));\n  for (const [name, info] of Object.entries(d.packages || {})) {\n    if (name && (info.link || !info.resolved)) delete d.packages[name];\n  }\n  fs.writeFileSync(p, JSON.stringify(d, null, 2));\n\" \"$TMPDIR/package-lock.json\"\n\nflatpak-node-generator --no-requests-cache \\\n  -o \"$FLATHUB_REPO/node-sources.json\" npm \"$TMPDIR/package-lock.json\"\n\n# Update metainfo with new release\nTODAY=$(date +%Y-%m-%d)\nsed -i \"s|  <releases>|  <releases>\\n    <release version=\\\"$VERSION\\\" date=\\\"$TODAY\\\" />|\" \"$METAINFO\"\necho \"Updated metainfo with release $VERSION.\"\n\necho \"\"\necho \"Done! Review the changes:\"\necho \"  $MANIFEST\"\necho \"  $METAINFO\"\necho \"  $FLATHUB_REPO/cargo-sources.json\"\necho \"  $FLATHUB_REPO/node-sources.json\"\n"
  },
  {
    "path": "npm/cli/.gitignore",
    "content": "yaak\nyaak.exe\n"
  },
  {
    "path": "npm/cli/bin/cli.js",
    "content": "#!/usr/bin/env node\n\nconst path = require(\"path\");\nconst childProcess = require(\"child_process\");\nconst { BINARY_NAME, PLATFORM_SPECIFIC_PACKAGE_NAME } = require(\"../common\");\n\nfunction getBinaryPath() {\n  try {\n    if (!PLATFORM_SPECIFIC_PACKAGE_NAME) {\n      throw new Error(\"unsupported platform\");\n    }\n    return require.resolve(`${PLATFORM_SPECIFIC_PACKAGE_NAME}/bin/${BINARY_NAME}`);\n  } catch (_) {\n    return path.join(__dirname, \"..\", BINARY_NAME);\n  }\n}\n\nconst result = childProcess.spawnSync(getBinaryPath(), process.argv.slice(2), {\n  stdio: \"inherit\",\n  env: { ...process.env, YAAK_CLI_INSTALL_SOURCE: process.env.YAAK_CLI_INSTALL_SOURCE ?? \"npm\" },\n});\n\nif (result.error) {\n  throw result.error;\n}\n\nif (result.signal) {\n  process.kill(process.pid, result.signal);\n}\n\nprocess.exit(result.status ?? 1);\n"
  },
  {
    "path": "npm/cli/common.js",
    "content": "const BINARY_DISTRIBUTION_PACKAGES = {\n  darwin_arm64: \"@yaakapp/cli-darwin-arm64\",\n  darwin_x64: \"@yaakapp/cli-darwin-x64\",\n  linux_arm64: \"@yaakapp/cli-linux-arm64\",\n  linux_x64: \"@yaakapp/cli-linux-x64\",\n  win32_x64: \"@yaakapp/cli-win32-x64\",\n  win32_arm64: \"@yaakapp/cli-win32-arm64\",\n};\n\nconst BINARY_DISTRIBUTION_VERSION = require(\"./package.json\").version;\nconst BINARY_NAME = process.platform === \"win32\" ? \"yaak.exe\" : \"yaak\";\nconst PLATFORM_SPECIFIC_PACKAGE_NAME =\n  BINARY_DISTRIBUTION_PACKAGES[`${process.platform}_${process.arch}`];\n\nmodule.exports = {\n  BINARY_DISTRIBUTION_PACKAGES,\n  BINARY_DISTRIBUTION_VERSION,\n  BINARY_NAME,\n  PLATFORM_SPECIFIC_PACKAGE_NAME,\n};\n"
  },
  {
    "path": "npm/cli/index.js",
    "content": "const path = require(\"path\");\nconst childProcess = require(\"child_process\");\nconst { PLATFORM_SPECIFIC_PACKAGE_NAME, BINARY_NAME } = require(\"./common\");\n\nfunction getBinaryPath() {\n  try {\n    if (!PLATFORM_SPECIFIC_PACKAGE_NAME) {\n      throw new Error(\"unsupported platform\");\n    }\n    return require.resolve(`${PLATFORM_SPECIFIC_PACKAGE_NAME}/bin/${BINARY_NAME}`);\n  } catch (_) {\n    return path.join(__dirname, BINARY_NAME);\n  }\n}\n\nmodule.exports.runBinary = function runBinary(...args) {\n  childProcess.execFileSync(getBinaryPath(), args, {\n    stdio: \"inherit\",\n    env: { ...process.env, YAAK_CLI_INSTALL_SOURCE: process.env.YAAK_CLI_INSTALL_SOURCE ?? \"npm\" },\n  });\n};\n"
  },
  {
    "path": "npm/cli/install.js",
    "content": "const fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst zlib = require(\"node:zlib\");\nconst https = require(\"node:https\");\nconst {\n  BINARY_DISTRIBUTION_VERSION,\n  BINARY_NAME,\n  PLATFORM_SPECIFIC_PACKAGE_NAME,\n} = require(\"./common\");\n\nconst fallbackBinaryPath = path.join(__dirname, BINARY_NAME);\n\nfunction makeRequest(url) {\n  return new Promise((resolve, reject) => {\n    https\n      .get(url, (response) => {\n        if (response.statusCode >= 200 && response.statusCode < 300) {\n          const chunks = [];\n          response.on(\"data\", (chunk) => chunks.push(chunk));\n          response.on(\"end\", () => resolve(Buffer.concat(chunks)));\n        } else if (\n          response.statusCode >= 300 &&\n          response.statusCode < 400 &&\n          response.headers.location\n        ) {\n          makeRequest(response.headers.location).then(resolve, reject);\n        } else {\n          reject(\n            new Error(\n              `npm responded with status code ${response.statusCode} when downloading package ${url}`,\n            ),\n          );\n        }\n      })\n      .on(\"error\", (error) => reject(error));\n  });\n}\n\nfunction extractFileFromTarball(tarballBuffer, filepath) {\n  let offset = 0;\n  while (offset < tarballBuffer.length) {\n    const header = tarballBuffer.subarray(offset, offset + 512);\n    offset += 512;\n\n    const fileName = header.toString(\"utf-8\", 0, 100).replace(/\\0.*/g, \"\");\n    const fileSize = parseInt(header.toString(\"utf-8\", 124, 136).replace(/\\0.*/g, \"\"), 8);\n\n    if (fileName === filepath) {\n      return tarballBuffer.subarray(offset, offset + fileSize);\n    }\n\n    offset = (offset + fileSize + 511) & ~511;\n  }\n\n  return null;\n}\n\nasync function downloadBinaryFromNpm() {\n  if (!PLATFORM_SPECIFIC_PACKAGE_NAME) {\n    throw new Error(`Unsupported platform: ${process.platform}/${process.arch}`);\n  }\n\n  const packageNameWithoutScope = PLATFORM_SPECIFIC_PACKAGE_NAME.split(\"/\")[1];\n  const tarballUrl = `https://registry.npmjs.org/${PLATFORM_SPECIFIC_PACKAGE_NAME}/-/${packageNameWithoutScope}-${BINARY_DISTRIBUTION_VERSION}.tgz`;\n  const tarballDownloadBuffer = await makeRequest(tarballUrl);\n  const tarballBuffer = zlib.unzipSync(tarballDownloadBuffer);\n\n  const binary = extractFileFromTarball(tarballBuffer, `package/bin/${BINARY_NAME}`);\n  if (!binary) {\n    throw new Error(`Could not find package/bin/${BINARY_NAME} in tarball`);\n  }\n\n  fs.writeFileSync(fallbackBinaryPath, binary);\n  fs.chmodSync(fallbackBinaryPath, \"755\");\n}\n\nfunction isPlatformSpecificPackageInstalled() {\n  try {\n    if (!PLATFORM_SPECIFIC_PACKAGE_NAME) {\n      return false;\n    }\n    require.resolve(`${PLATFORM_SPECIFIC_PACKAGE_NAME}/bin/${BINARY_NAME}`);\n    return true;\n  } catch (_) {\n    return false;\n  }\n}\n\nif (!isPlatformSpecificPackageInstalled()) {\n  console.log(\"Platform package missing. Downloading Yaak CLI binary from npm...\");\n  downloadBinaryFromNpm().catch((err) => {\n    console.error(\"Failed to install Yaak CLI binary:\", err);\n    process.exitCode = 1;\n  });\n} else {\n  console.log(\"Platform package present. Using bundled Yaak CLI binary.\");\n}\n"
  },
  {
    "path": "npm/cli/package.json",
    "content": "{\n  \"name\": \"@yaakapp/cli\",\n  \"version\": \"0.0.1\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/mountain-loop/yaak.git\"\n  },\n  \"bin\": {\n    \"yaak\": \"bin/cli.js\",\n    \"yaakcli\": \"bin/cli.js\"\n  },\n  \"main\": \"./index.js\",\n  \"scripts\": {\n    \"postinstall\": \"node ./install.js\",\n    \"prepublishOnly\": \"node ./prepublish.js\"\n  },\n  \"optionalDependencies\": {\n    \"@yaakapp/cli-darwin-arm64\": \"0.0.1\",\n    \"@yaakapp/cli-darwin-x64\": \"0.0.1\",\n    \"@yaakapp/cli-linux-arm64\": \"0.0.1\",\n    \"@yaakapp/cli-linux-x64\": \"0.0.1\",\n    \"@yaakapp/cli-win32-arm64\": \"0.0.1\",\n    \"@yaakapp/cli-win32-x64\": \"0.0.1\"\n  }\n}\n"
  },
  {
    "path": "npm/cli/prepublish.js",
    "content": "const fs = require(\"node:fs\");\nconst path = require(\"node:path\");\n\nconst cliReadme = path.join(__dirname, \"..\", \"..\", \"crates-cli\", \"yaak-cli\", \"README.md\");\nfs.copyFileSync(cliReadme, path.join(__dirname, \"README.md\"));\n"
  },
  {
    "path": "npm/cli-darwin-arm64/bin/.gitkeep",
    "content": ""
  },
  {
    "path": "npm/cli-darwin-arm64/package.json",
    "content": "{\n  \"name\": \"@yaakapp/cli-darwin-arm64\",\n  \"version\": \"0.0.1\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/mountain-loop/yaak.git\"\n  },\n  \"os\": [\n    \"darwin\"\n  ],\n  \"cpu\": [\n    \"arm64\"\n  ]\n}\n"
  },
  {
    "path": "npm/cli-darwin-x64/bin/.gitkeep",
    "content": ""
  },
  {
    "path": "npm/cli-darwin-x64/package.json",
    "content": "{\n  \"name\": \"@yaakapp/cli-darwin-x64\",\n  \"version\": \"0.0.1\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/mountain-loop/yaak.git\"\n  },\n  \"os\": [\n    \"darwin\"\n  ],\n  \"cpu\": [\n    \"x64\"\n  ]\n}\n"
  },
  {
    "path": "npm/cli-linux-arm64/bin/.gitkeep",
    "content": ""
  },
  {
    "path": "npm/cli-linux-arm64/package.json",
    "content": "{\n  \"name\": \"@yaakapp/cli-linux-arm64\",\n  \"version\": \"0.0.1\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/mountain-loop/yaak.git\"\n  },\n  \"os\": [\n    \"linux\"\n  ],\n  \"cpu\": [\n    \"arm64\"\n  ]\n}\n"
  },
  {
    "path": "npm/cli-linux-x64/bin/.gitkeep",
    "content": ""
  },
  {
    "path": "npm/cli-linux-x64/package.json",
    "content": "{\n  \"name\": \"@yaakapp/cli-linux-x64\",\n  \"version\": \"0.0.1\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/mountain-loop/yaak.git\"\n  },\n  \"os\": [\n    \"linux\"\n  ],\n  \"cpu\": [\n    \"x64\"\n  ]\n}\n"
  },
  {
    "path": "npm/cli-win32-arm64/bin/.gitkeep",
    "content": ""
  },
  {
    "path": "npm/cli-win32-arm64/package.json",
    "content": "{\n  \"name\": \"@yaakapp/cli-win32-arm64\",\n  \"version\": \"0.0.1\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/mountain-loop/yaak.git\"\n  },\n  \"os\": [\n    \"win32\"\n  ],\n  \"cpu\": [\n    \"arm64\"\n  ]\n}\n"
  },
  {
    "path": "npm/cli-win32-x64/bin/.gitkeep",
    "content": ""
  },
  {
    "path": "npm/cli-win32-x64/package.json",
    "content": "{\n  \"name\": \"@yaakapp/cli-win32-x64\",\n  \"version\": \"0.0.1\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/mountain-loop/yaak.git\"\n  },\n  \"os\": [\n    \"win32\"\n  ],\n  \"cpu\": [\n    \"x64\"\n  ]\n}\n"
  },
  {
    "path": "npm/prepare-publish.js",
    "content": "const { chmodSync, copyFileSync, existsSync, readFileSync, writeFileSync } = require(\"node:fs\");\nconst { join } = require(\"node:path\");\n\nconst version = process.env.YAAK_CLI_VERSION?.replace(/^v/, \"\");\nif (!version) {\n  console.error(\"YAAK_CLI_VERSION is not set\");\n  process.exit(1);\n}\n\nconst packages = [\n  \"cli\",\n  \"cli-darwin-arm64\",\n  \"cli-darwin-x64\",\n  \"cli-linux-arm64\",\n  \"cli-linux-x64\",\n  \"cli-win32-arm64\",\n  \"cli-win32-x64\",\n];\n\nconst binaries = [\n  {\n    src: join(__dirname, \"dist\", \"cli-darwin-arm64\", \"yaak\"),\n    dest: join(__dirname, \"cli-darwin-arm64\", \"bin\", \"yaak\"),\n  },\n  {\n    src: join(__dirname, \"dist\", \"cli-darwin-x64\", \"yaak\"),\n    dest: join(__dirname, \"cli-darwin-x64\", \"bin\", \"yaak\"),\n  },\n  {\n    src: join(__dirname, \"dist\", \"cli-linux-arm64\", \"yaak\"),\n    dest: join(__dirname, \"cli-linux-arm64\", \"bin\", \"yaak\"),\n  },\n  {\n    src: join(__dirname, \"dist\", \"cli-linux-x64\", \"yaak\"),\n    dest: join(__dirname, \"cli-linux-x64\", \"bin\", \"yaak\"),\n  },\n  {\n    src: join(__dirname, \"dist\", \"cli-win32-arm64\", \"yaak.exe\"),\n    dest: join(__dirname, \"cli-win32-arm64\", \"bin\", \"yaak.exe\"),\n  },\n  {\n    src: join(__dirname, \"dist\", \"cli-win32-x64\", \"yaak.exe\"),\n    dest: join(__dirname, \"cli-win32-x64\", \"bin\", \"yaak.exe\"),\n  },\n];\n\nfor (const { src, dest } of binaries) {\n  if (!existsSync(src)) {\n    console.error(`Missing binary artifact: ${src}`);\n    process.exit(1);\n  }\n  copyFileSync(src, dest);\n  if (!dest.endsWith(\".exe\")) {\n    chmodSync(dest, 0o755);\n  }\n}\n\nfor (const pkg of packages) {\n  const filepath = join(__dirname, pkg, \"package.json\");\n  const json = JSON.parse(readFileSync(filepath, \"utf-8\"));\n  json.version = version;\n\n  if (json.name === \"@yaakapp/cli\") {\n    json.optionalDependencies = {\n      \"@yaakapp/cli-darwin-x64\": version,\n      \"@yaakapp/cli-darwin-arm64\": version,\n      \"@yaakapp/cli-linux-arm64\": version,\n      \"@yaakapp/cli-linux-x64\": version,\n      \"@yaakapp/cli-win32-x64\": version,\n      \"@yaakapp/cli-win32-arm64\": version,\n    };\n  }\n\n  writeFileSync(filepath, `${JSON.stringify(json, null, 2)}\\n`);\n}\n\nconsole.log(`Prepared @yaakapp/cli npm packages for ${version}`);\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"yaak-app\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/mountain-loop/yaak.git\"\n  },\n  \"workspaces\": [\n    \"packages/common-lib\",\n    \"packages/plugin-runtime\",\n    \"packages/plugin-runtime-types\",\n    \"plugins-external/mcp-server\",\n    \"plugins-external/faker\",\n    \"plugins-external/httpsnippet\",\n    \"plugins/action-copy-curl\",\n    \"plugins/action-copy-grpcurl\",\n    \"plugins/action-send-folder\",\n    \"plugins/auth-apikey\",\n    \"plugins/auth-aws\",\n    \"plugins/auth-basic\",\n    \"plugins/auth-bearer\",\n    \"plugins/auth-jwt\",\n    \"plugins/auth-ntlm\",\n    \"plugins/auth-oauth2\",\n    \"plugins/auth-oauth1\",\n    \"plugins/filter-jsonpath\",\n    \"plugins/filter-xpath\",\n    \"plugins/importer-curl\",\n    \"plugins/importer-insomnia\",\n    \"plugins/importer-openapi\",\n    \"plugins/importer-postman\",\n    \"plugins/importer-postman-environment\",\n    \"plugins/importer-yaak\",\n    \"plugins/template-function-1password\",\n    \"plugins/template-function-cookie\",\n    \"plugins/template-function-ctx\",\n    \"plugins/template-function-encode\",\n    \"plugins/template-function-fs\",\n    \"plugins/template-function-hash\",\n    \"plugins/template-function-json\",\n    \"plugins/template-function-prompt\",\n    \"plugins/template-function-random\",\n    \"plugins/template-function-regex\",\n    \"plugins/template-function-timestamp\",\n    \"plugins/template-function-uuid\",\n    \"plugins/template-function-xml\",\n    \"plugins/template-function-request\",\n    \"plugins/template-function-response\",\n    \"plugins/themes-yaak\",\n    \"crates-tauri/yaak-app\",\n    \"crates-tauri/yaak-fonts\",\n    \"crates-tauri/yaak-license\",\n    \"crates-tauri/yaak-mac-window\",\n    \"crates/yaak-crypto\",\n    \"crates/yaak-git\",\n    \"crates/yaak-models\",\n    \"crates/yaak-plugins\",\n    \"crates/yaak-sse\",\n    \"crates/yaak-sync\",\n    \"crates/yaak-templates\",\n    \"crates/yaak-ws\",\n    \"src-web\"\n  ],\n  \"scripts\": {\n    \"prepare\": \"vp config\",\n    \"init\": \"npm install && npm run bootstrap\",\n    \"start\": \"npm run app-dev\",\n    \"app-build\": \"tauri build\",\n    \"app-dev\": \"node scripts/run-dev.mjs\",\n    \"migration\": \"node scripts/create-migration.cjs\",\n    \"build\": \"npm run --workspaces --if-present build\",\n    \"test\": \"npm run --workspaces --if-present test\",\n    \"icons\": \"run-p icons:*\",\n    \"icons:dev\": \"tauri icon crates-tauri/yaak-app/icons/icon-dev.png --output crates-tauri/yaak-app/icons/dev\",\n    \"icons:release\": \"tauri icon crates-tauri/yaak-app/icons/icon.png --output crates-tauri/yaak-app/icons/release\",\n    \"bootstrap\": \"run-s bootstrap:*\",\n    \"bootstrap:install-wasm-pack\": \"node scripts/install-wasm-pack.cjs\",\n    \"bootstrap:build\": \"npm run build\",\n    \"bootstrap:vendor\": \"npm run vendor\",\n    \"vendor\": \"run-p vendor:*\",\n    \"vendor:vendor-plugins\": \"node scripts/vendor-plugins.cjs\",\n    \"vendor:vendor-protoc\": \"node scripts/vendor-protoc.cjs\",\n    \"vendor:vendor-node\": \"node scripts/vendor-node.cjs\",\n    \"format\": \"vp fmt --ignore-path .oxfmtignore\",\n    \"lint\": \"run-p lint:*\",\n    \"lint:vp\": \"vp lint\",\n    \"lint:workspaces\": \"npm run --workspaces --if-present lint\",\n    \"replace-version\": \"node scripts/replace-version.cjs\",\n    \"tauri\": \"tauri\",\n    \"tauri-before-build\": \"npm run bootstrap\",\n    \"tauri-before-dev\": \"node scripts/run-workspaces-dev.mjs\"\n  },\n  \"dependencies\": {\n    \"@codemirror/lang-go\": \"^6.0.1\",\n    \"@codemirror/lang-java\": \"^6.0.2\",\n    \"@codemirror/lang-php\": \"^6.0.2\",\n    \"@codemirror/lang-python\": \"^6.2.1\",\n    \"@codemirror/legacy-modes\": \"^6.5.2\"\n  },\n  \"devDependencies\": {\n    \"@tauri-apps/cli\": \"^2.9.6\",\n    \"@yaakapp/cli\": \"^0.5.1\",\n    \"dotenv-cli\": \"^11.0.0\",\n    \"nodejs-file-downloader\": \"^4.13.0\",\n    \"npm-run-all\": \"^4.1.5\",\n    \"typescript\": \"^5.8.3\",\n    \"vite-plus\": \"latest\",\n    \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n  },\n  \"overrides\": {\n    \"js-yaml\": \"^4.1.1\",\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n  },\n  \"packageManager\": \"npm@11.11.1\"\n}\n"
  },
  {
    "path": "packages/common-lib/debounce.ts",
    "content": "// oxlint-disable-next-line no-explicit-any\nexport function debounce(fn: (...args: any[]) => void, delay = 500) {\n  let timer: ReturnType<typeof setTimeout>;\n  // oxlint-disable-next-line no-explicit-any\n  const result = (...args: any[]) => {\n    clearTimeout(timer);\n    timer = setTimeout(() => fn(...args), delay);\n  };\n  result.cancel = () => {\n    clearTimeout(timer);\n  };\n  return result;\n}\n"
  },
  {
    "path": "packages/common-lib/formatSize.ts",
    "content": "export function formatSize(bytes: number): string {\n  let num: number;\n  let unit: string;\n\n  if (bytes > 1000 * 1000 * 1000) {\n    num = bytes / 1000 / 1000 / 1000;\n    unit = \"GB\";\n  } else if (bytes > 1000 * 1000) {\n    num = bytes / 1000 / 1000;\n    unit = \"MB\";\n  } else if (bytes > 1000) {\n    num = bytes / 1000;\n    unit = \"KB\";\n  } else {\n    num = bytes;\n    unit = \"B\";\n  }\n\n  return `${Math.round(num * 10) / 10} ${unit}`;\n}\n"
  },
  {
    "path": "packages/common-lib/index.ts",
    "content": "export * from \"./debounce\";\nexport * from \"./formatSize\";\nexport * from \"./templateFunction\";\n"
  },
  {
    "path": "packages/common-lib/package.json",
    "content": "{\n  \"name\": \"@yaakapp-internal/lib\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"main\": \"index.ts\"\n}\n"
  },
  {
    "path": "packages/common-lib/templateFunction.ts",
    "content": "import type {\n  CallTemplateFunctionArgs,\n  JsonPrimitive,\n  TemplateFunctionArg,\n} from \"@yaakapp-internal/plugins\";\n\nexport function validateTemplateFunctionArgs(\n  fnName: string,\n  args: TemplateFunctionArg[],\n  values: CallTemplateFunctionArgs[\"values\"],\n): string | null {\n  for (const arg of args) {\n    if (\"inputs\" in arg && arg.inputs) {\n      // Recurse down\n      const err = validateTemplateFunctionArgs(fnName, arg.inputs, values);\n      if (err) return err;\n    }\n    if (!(\"name\" in arg)) continue;\n    if (arg.optional) continue;\n    if (arg.defaultValue != null) continue;\n    if (arg.hidden) continue;\n    if (values[arg.name] != null) continue;\n\n    return `Missing required argument \"${arg.label || arg.name}\" for template function ${fnName}()`;\n  }\n\n  return null;\n}\n\n/** Recursively apply form input defaults to a set of values */\nexport function applyFormInputDefaults(\n  inputs: TemplateFunctionArg[],\n  values: { [p: string]: JsonPrimitive | undefined },\n) {\n  let newValues: { [p: string]: JsonPrimitive | undefined } = { ...values };\n  for (const input of inputs) {\n    if (\"defaultValue\" in input && values[input.name] === undefined) {\n      newValues[input.name] = input.defaultValue;\n    }\n    if (input.type === \"checkbox\" && values[input.name] === undefined) {\n      newValues[input.name] = false;\n    }\n    // Recurse down to all child inputs\n    if (\"inputs\" in input) {\n      newValues = applyFormInputDefaults(input.inputs ?? [], newValues);\n    }\n  }\n  return newValues;\n}\n"
  },
  {
    "path": "packages/plugin-runtime/package.json",
    "content": "{\n  \"name\": \"@yaakapp-internal/plugin-runtime\",\n  \"scripts\": {\n    \"bootstrap\": \"npm run build\",\n    \"build\": \"run-p build:*\",\n    \"build:main\": \"esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs\"\n  },\n  \"dependencies\": {\n    \"ws\": \"^8.18.0\"\n  },\n  \"devDependencies\": {\n    \"@types/ws\": \"^8.5.13\"\n  }\n}\n"
  },
  {
    "path": "packages/plugin-runtime/src/EventChannel.ts",
    "content": "import type { InternalEvent } from \"@yaakapp/api\";\n\nexport class EventChannel {\n  #listeners = new Set<(event: InternalEvent) => void>();\n\n  emit(e: InternalEvent) {\n    for (const l of this.#listeners) {\n      l(e);\n    }\n  }\n\n  listen(cb: (e: InternalEvent) => void) {\n    this.#listeners.add(cb);\n  }\n\n  unlisten(cb: (e: InternalEvent) => void) {\n    this.#listeners.delete(cb);\n  }\n}\n"
  },
  {
    "path": "packages/plugin-runtime/src/PluginHandle.ts",
    "content": "import type { BootRequest, InternalEvent } from \"@yaakapp/api\";\nimport type { PluginContext } from \"@yaakapp-internal/plugins\";\nimport type { EventChannel } from \"./EventChannel\";\nimport { PluginInstance, type PluginWorkerData } from \"./PluginInstance\";\n\nexport class PluginHandle {\n  #instance: PluginInstance;\n\n  constructor(\n    pluginRefId: string,\n    context: PluginContext,\n    bootRequest: BootRequest,\n    pluginToAppEvents: EventChannel,\n  ) {\n    const workerData: PluginWorkerData = { pluginRefId, context, bootRequest };\n    this.#instance = new PluginInstance(workerData, pluginToAppEvents);\n  }\n\n  sendToWorker(event: InternalEvent) {\n    this.#instance.postMessage(event);\n  }\n\n  async terminate() {\n    await this.#instance.terminate();\n  }\n}\n"
  },
  {
    "path": "packages/plugin-runtime/src/PluginInstance.ts",
    "content": "import console from \"node:console\";\nimport { type Stats, statSync, watch } from \"node:fs\";\nimport path from \"node:path\";\nimport type {\n  CallPromptFormDynamicArgs,\n  Context,\n  DynamicPromptFormArg,\n  PluginDefinition,\n} from \"@yaakapp/api\";\nimport {\n  applyFormInputDefaults,\n  validateTemplateFunctionArgs,\n} from \"@yaakapp-internal/lib/templateFunction\";\nimport type {\n  BootRequest,\n  DeleteKeyValueResponse,\n  DeleteModelResponse,\n  FindHttpResponsesResponse,\n  Folder,\n  FormInput,\n  GetCookieValueRequest,\n  GetCookieValueResponse,\n  GetHttpRequestByIdResponse,\n  GetKeyValueResponse,\n  GrpcRequestAction,\n  HttpAuthenticationAction,\n  HttpRequest,\n  HttpRequestAction,\n  ImportResources,\n  InternalEvent,\n  InternalEventPayload,\n  ListCookieNamesResponse,\n  ListFoldersResponse,\n  ListHttpRequestsRequest,\n  ListHttpRequestsResponse,\n  ListOpenWorkspacesResponse,\n  PluginContext,\n  PromptFormResponse,\n  PromptTextResponse,\n  RenderGrpcRequestResponse,\n  RenderHttpRequestResponse,\n  SendHttpRequestResponse,\n  TemplateFunction,\n  TemplateRenderRequest,\n  TemplateRenderResponse,\n  UpsertModelResponse,\n  WindowInfoResponse,\n} from \"@yaakapp-internal/plugins\";\nimport { applyDynamicFormInput } from \"./common\";\nimport { EventChannel } from \"./EventChannel\";\nimport { migrateTemplateFunctionSelectOptions } from \"./migrations\";\n\nexport interface PluginWorkerData {\n  bootRequest: BootRequest;\n  pluginRefId: string;\n  context: PluginContext;\n}\n\nexport class PluginInstance {\n  #workerData: PluginWorkerData;\n  #mod: PluginDefinition;\n  #pluginToAppEvents: EventChannel;\n  #appToPluginEvents: EventChannel;\n  #pendingDynamicForms = new Map<string, DynamicPromptFormArg[]>();\n\n  constructor(workerData: PluginWorkerData, pluginEvents: EventChannel) {\n    this.#workerData = workerData;\n    this.#pluginToAppEvents = pluginEvents;\n    this.#appToPluginEvents = new EventChannel();\n\n    // Forward incoming events to onMessage()\n    this.#appToPluginEvents.listen(async (event) => {\n      await this.#onMessage(event);\n    });\n\n    this.#mod = {};\n\n    const fileChangeCallback = async () => {\n      const ctx = this.#newCtx(workerData.context);\n      try {\n        await this.#mod?.dispose?.();\n        this.#importModule();\n        await this.#mod?.init?.(ctx);\n        this.#sendPayload(\n          workerData.context,\n          {\n            type: \"reload_response\",\n            silent: false,\n          },\n          null,\n        );\n      } catch (err: unknown) {\n        await ctx.toast.show({\n          message: `Failed to initialize plugin ${this.#workerData.bootRequest.dir.split(\"/\").pop()}: ${err instanceof Error ? err.message : String(err)}`,\n          color: \"notice\",\n          icon: \"alert_triangle\",\n          timeout: 30000,\n        });\n      }\n    };\n\n    if (this.#workerData.bootRequest.watch) {\n      watchFile(this.#pathMod(), fileChangeCallback);\n      watchFile(this.#pathPkg(), fileChangeCallback);\n    }\n\n    this.#importModule();\n  }\n\n  postMessage(event: InternalEvent) {\n    this.#appToPluginEvents.emit(event);\n  }\n\n  async terminate() {\n    await this.#mod?.dispose?.();\n    this.#pendingDynamicForms.clear();\n    this.#unimportModule();\n  }\n\n  async #onMessage(event: InternalEvent) {\n    const ctx = this.#newCtx(event.context);\n\n    const { context, payload, id: replyId } = event;\n\n    try {\n      if (payload.type === \"boot_request\") {\n        await this.#mod?.init?.(ctx);\n        this.#sendPayload(context, { type: \"boot_response\" }, replyId);\n        return;\n      }\n\n      if (payload.type === \"terminate_request\") {\n        const payload: InternalEventPayload = {\n          type: \"terminate_response\",\n        };\n        await this.terminate();\n        this.#sendPayload(context, payload, replyId);\n        return;\n      }\n\n      if (\n        payload.type === \"import_request\" &&\n        typeof this.#mod?.importer?.onImport === \"function\"\n      ) {\n        const reply = await this.#mod.importer.onImport(ctx, {\n          text: payload.content,\n        });\n        if (reply != null) {\n          const replyPayload: InternalEventPayload = {\n            type: \"import_response\",\n            resources: reply.resources as ImportResources,\n          };\n          this.#sendPayload(context, replyPayload, replyId);\n          return;\n        } else {\n          // Send back an empty reply (below)\n        }\n      }\n\n      if (payload.type === \"filter_request\" && typeof this.#mod?.filter?.onFilter === \"function\") {\n        const reply = await this.#mod.filter.onFilter(ctx, {\n          filter: payload.filter,\n          payload: payload.content,\n          mimeType: payload.type,\n        });\n        this.#sendPayload(context, { type: \"filter_response\", ...reply }, replyId);\n        return;\n      }\n\n      if (\n        payload.type === \"get_grpc_request_actions_request\" &&\n        Array.isArray(this.#mod?.grpcRequestActions)\n      ) {\n        const reply: GrpcRequestAction[] = this.#mod.grpcRequestActions.map((a) => ({\n          ...a,\n          // Add everything except onSelect\n          onSelect: undefined,\n        }));\n        const replyPayload: InternalEventPayload = {\n          type: \"get_grpc_request_actions_response\",\n          pluginRefId: this.#workerData.pluginRefId,\n          actions: reply,\n        };\n        this.#sendPayload(context, replyPayload, replyId);\n        return;\n      }\n\n      if (\n        payload.type === \"get_http_request_actions_request\" &&\n        Array.isArray(this.#mod?.httpRequestActions)\n      ) {\n        const reply: HttpRequestAction[] = this.#mod.httpRequestActions.map((a) => ({\n          ...a,\n          // Add everything except onSelect\n          onSelect: undefined,\n        }));\n        const replyPayload: InternalEventPayload = {\n          type: \"get_http_request_actions_response\",\n          pluginRefId: this.#workerData.pluginRefId,\n          actions: reply,\n        };\n        this.#sendPayload(context, replyPayload, replyId);\n        return;\n      }\n\n      if (\n        payload.type === \"get_websocket_request_actions_request\" &&\n        Array.isArray(this.#mod?.websocketRequestActions)\n      ) {\n        const reply = this.#mod.websocketRequestActions.map((a) => ({\n          ...a,\n          onSelect: undefined,\n        }));\n        const replyPayload: InternalEventPayload = {\n          type: \"get_websocket_request_actions_response\",\n          pluginRefId: this.#workerData.pluginRefId,\n          actions: reply,\n        };\n        this.#sendPayload(context, replyPayload, replyId);\n        return;\n      }\n\n      if (\n        payload.type === \"get_workspace_actions_request\" &&\n        Array.isArray(this.#mod?.workspaceActions)\n      ) {\n        const reply = this.#mod.workspaceActions.map((a) => ({\n          ...a,\n          onSelect: undefined,\n        }));\n        const replyPayload: InternalEventPayload = {\n          type: \"get_workspace_actions_response\",\n          pluginRefId: this.#workerData.pluginRefId,\n          actions: reply,\n        };\n        this.#sendPayload(context, replyPayload, replyId);\n        return;\n      }\n\n      if (\n        payload.type === \"get_folder_actions_request\" &&\n        Array.isArray(this.#mod?.folderActions)\n      ) {\n        const reply = this.#mod.folderActions.map((a) => ({\n          ...a,\n          onSelect: undefined,\n        }));\n        const replyPayload: InternalEventPayload = {\n          type: \"get_folder_actions_response\",\n          pluginRefId: this.#workerData.pluginRefId,\n          actions: reply,\n        };\n        this.#sendPayload(context, replyPayload, replyId);\n        return;\n      }\n\n      if (payload.type === \"get_themes_request\" && Array.isArray(this.#mod?.themes)) {\n        const replyPayload: InternalEventPayload = {\n          type: \"get_themes_response\",\n          themes: this.#mod.themes,\n        };\n        this.#sendPayload(context, replyPayload, replyId);\n        return;\n      }\n\n      if (\n        payload.type === \"get_template_function_summary_request\" &&\n        Array.isArray(this.#mod?.templateFunctions)\n      ) {\n        const functions: TemplateFunction[] = this.#mod.templateFunctions.map(\n          (templateFunction) => {\n            return {\n              ...migrateTemplateFunctionSelectOptions(templateFunction),\n              // Add everything except render\n              onRender: undefined,\n            };\n          },\n        );\n        const replyPayload: InternalEventPayload = {\n          type: \"get_template_function_summary_response\",\n          pluginRefId: this.#workerData.pluginRefId,\n          functions,\n        };\n        this.#sendPayload(context, replyPayload, replyId);\n        return;\n      }\n\n      if (\n        payload.type === \"get_template_function_config_request\" &&\n        Array.isArray(this.#mod?.templateFunctions)\n      ) {\n        const templateFunction = this.#mod.templateFunctions.find((f) => f.name === payload.name);\n        if (templateFunction == null) {\n          this.#sendEmpty(context, replyId);\n          return;\n        }\n\n        const fn = {\n          ...migrateTemplateFunctionSelectOptions(templateFunction),\n          onRender: undefined,\n        };\n\n        payload.values = applyFormInputDefaults(fn.args, payload.values);\n        const p = { ...payload, purpose: \"preview\" } as const;\n        const resolvedArgs = await applyDynamicFormInput(ctx, fn.args, p);\n\n        const replyPayload: InternalEventPayload = {\n          type: \"get_template_function_config_response\",\n          pluginRefId: this.#workerData.pluginRefId,\n          function: { ...fn, args: stripDynamicCallbacks(resolvedArgs) },\n        };\n        this.#sendPayload(context, replyPayload, replyId);\n        return;\n      }\n\n      if (payload.type === \"get_http_authentication_summary_request\" && this.#mod?.authentication) {\n        const replyPayload: InternalEventPayload = {\n          type: \"get_http_authentication_summary_response\",\n          ...this.#mod.authentication,\n        };\n\n        this.#sendPayload(context, replyPayload, replyId);\n        return;\n      }\n\n      if (payload.type === \"get_http_authentication_config_request\" && this.#mod?.authentication) {\n        const { args, actions } = this.#mod.authentication;\n        payload.values = applyFormInputDefaults(args, payload.values);\n        const resolvedArgs = await applyDynamicFormInput(ctx, args, payload);\n        const resolvedActions: HttpAuthenticationAction[] = [];\n        // oxlint-disable-next-line unbound-method\n        for (const { onSelect: _onSelect, ...action } of actions ?? []) {\n          resolvedActions.push(action);\n        }\n\n        const replyPayload: InternalEventPayload = {\n          type: \"get_http_authentication_config_response\",\n          args: stripDynamicCallbacks(resolvedArgs),\n          actions: resolvedActions,\n          pluginRefId: this.#workerData.pluginRefId,\n        };\n\n        this.#sendPayload(context, replyPayload, replyId);\n        return;\n      }\n\n      if (payload.type === \"call_http_authentication_request\" && this.#mod?.authentication) {\n        const auth = this.#mod.authentication;\n        if (typeof auth?.onApply === \"function\") {\n          const resolvedArgs = await applyDynamicFormInput(ctx, auth.args, payload);\n          payload.values = applyFormInputDefaults(resolvedArgs, payload.values);\n          this.#sendPayload(\n            context,\n            {\n              type: \"call_http_authentication_response\",\n              ...(await auth.onApply(ctx, payload)),\n            },\n            replyId,\n          );\n          return;\n        }\n      }\n\n      if (\n        payload.type === \"call_http_authentication_action_request\" &&\n        this.#mod.authentication != null\n      ) {\n        const action = this.#mod.authentication.actions?.[payload.index];\n        if (typeof action?.onSelect === \"function\") {\n          await action.onSelect(ctx, payload.args);\n          this.#sendEmpty(context, replyId);\n          return;\n        }\n      }\n\n      if (\n        payload.type === \"call_http_request_action_request\" &&\n        Array.isArray(this.#mod.httpRequestActions)\n      ) {\n        const action = this.#mod.httpRequestActions[payload.index];\n        if (typeof action?.onSelect === \"function\") {\n          await action.onSelect(ctx, payload.args);\n          this.#sendEmpty(context, replyId);\n          return;\n        }\n      }\n\n      if (\n        payload.type === \"call_websocket_request_action_request\" &&\n        Array.isArray(this.#mod.websocketRequestActions)\n      ) {\n        const action = this.#mod.websocketRequestActions[payload.index];\n        if (typeof action?.onSelect === \"function\") {\n          await action.onSelect(ctx, payload.args);\n          this.#sendEmpty(context, replyId);\n          return;\n        }\n      }\n\n      if (\n        payload.type === \"call_workspace_action_request\" &&\n        Array.isArray(this.#mod.workspaceActions)\n      ) {\n        const action = this.#mod.workspaceActions[payload.index];\n        if (typeof action?.onSelect === \"function\") {\n          await action.onSelect(ctx, payload.args);\n          this.#sendEmpty(context, replyId);\n          return;\n        }\n      }\n\n      if (payload.type === \"call_folder_action_request\" && Array.isArray(this.#mod.folderActions)) {\n        const action = this.#mod.folderActions[payload.index];\n        if (typeof action?.onSelect === \"function\") {\n          await action.onSelect(ctx, payload.args);\n          this.#sendEmpty(context, replyId);\n          return;\n        }\n      }\n\n      if (\n        payload.type === \"call_grpc_request_action_request\" &&\n        Array.isArray(this.#mod.grpcRequestActions)\n      ) {\n        const action = this.#mod.grpcRequestActions[payload.index];\n        if (typeof action?.onSelect === \"function\") {\n          await action.onSelect(ctx, payload.args);\n          this.#sendEmpty(context, replyId);\n          return;\n        }\n      }\n\n      if (\n        payload.type === \"call_template_function_request\" &&\n        Array.isArray(this.#mod?.templateFunctions)\n      ) {\n        const fn = this.#mod.templateFunctions.find((a) => a.name === payload.name);\n        if (\n          payload.args.purpose === \"preview\" &&\n          (fn?.previewType === \"click\" || fn?.previewType === \"none\")\n        ) {\n          // Send empty render response\n          this.#sendPayload(\n            context,\n            {\n              type: \"call_template_function_response\",\n              value: null,\n              error: \"Live preview disabled for this function\",\n            },\n            replyId,\n          );\n        } else if (typeof fn?.onRender === \"function\") {\n          const resolvedArgs = await applyDynamicFormInput(ctx, fn.args, payload.args);\n          const values = applyFormInputDefaults(resolvedArgs, payload.args.values);\n          const error = validateTemplateFunctionArgs(fn.name, resolvedArgs, values);\n          if (error && payload.args.purpose !== \"preview\") {\n            this.#sendPayload(\n              context,\n              { type: \"call_template_function_response\", value: null, error },\n              replyId,\n            );\n            return;\n          }\n\n          try {\n            const result = await fn.onRender(ctx, { ...payload.args, values });\n            this.#sendPayload(\n              context,\n              { type: \"call_template_function_response\", value: result ?? null },\n              replyId,\n            );\n          } catch (err) {\n            this.#sendPayload(\n              context,\n              {\n                type: \"call_template_function_response\",\n                value: null,\n                error: (err instanceof Error ? err.message : String(err)).replace(\n                  /^Error:\\s*/g,\n                  \"\",\n                ),\n              },\n              replyId,\n            );\n          }\n          return;\n        }\n      }\n    } catch (err) {\n      const error = (err instanceof Error ? err.message : String(err)).replace(/^Error:\\s*/g, \"\");\n      console.log(\"Plugin call threw exception\", payload.type, \"→\", error);\n      this.#sendPayload(context, { type: \"error_response\", error }, replyId);\n      return;\n    }\n\n    // No matches, so send back an empty response so the caller doesn't block forever\n    this.#sendEmpty(context, replyId);\n  }\n\n  #pathMod() {\n    return path.posix.join(this.#workerData.bootRequest.dir, \"build\", \"index.js\");\n  }\n\n  #pathPkg() {\n    return path.join(this.#workerData.bootRequest.dir, \"package.json\");\n  }\n\n  #unimportModule() {\n    const id = require.resolve(this.#pathMod());\n    delete require.cache[id];\n  }\n\n  #importModule() {\n    const id = require.resolve(this.#pathMod());\n    delete require.cache[id];\n    this.#mod = require(id).plugin;\n  }\n\n  #buildEventToSend(\n    context: PluginContext,\n    payload: InternalEventPayload,\n    replyId: string | null = null,\n  ): InternalEvent {\n    return {\n      pluginRefId: this.#workerData.pluginRefId,\n      pluginName: path.basename(this.#workerData.bootRequest.dir),\n      id: genId(),\n      replyId,\n      payload,\n      context,\n    };\n  }\n\n  #sendPayload(\n    context: PluginContext,\n    payload: InternalEventPayload,\n    replyId: string | null,\n  ): string {\n    const event = this.#buildEventToSend(context, payload, replyId);\n    this.#sendEvent(event);\n    return event.id;\n  }\n\n  #sendEvent(event: InternalEvent) {\n    // if (event.payload.type !== 'empty_response') {\n    //   console.log('Sending event to app', this.#pkg.name, event.id, event.payload.type);\n    // }\n    this.#pluginToAppEvents.emit(event);\n  }\n\n  #sendEmpty(context: PluginContext, replyId: string | null = null): string {\n    return this.#sendPayload(context, { type: \"empty_response\" }, replyId);\n  }\n\n  #sendForReply<T extends Omit<InternalEventPayload, \"type\">>(\n    context: PluginContext,\n    payload: InternalEventPayload,\n  ): Promise<T> {\n    // 1. Build event to send\n    const eventToSend = this.#buildEventToSend(context, payload, null);\n\n    // 2. Spawn listener in background\n    const promise = new Promise<T>((resolve) => {\n      const cb = (event: InternalEvent) => {\n        if (event.replyId === eventToSend.id) {\n          this.#appToPluginEvents.unlisten(cb); // Unlisten, now that we're done\n          const { type: _, ...payload } = event.payload;\n          resolve(payload as T);\n        }\n      };\n      this.#appToPluginEvents.listen(cb);\n    });\n\n    // 3. Send the event after we start listening (to prevent race)\n    this.#sendEvent(eventToSend);\n\n    // 4. Return the listener promise\n    return promise as unknown as Promise<T>;\n  }\n\n  #sendAndListenForEvents(\n    context: PluginContext,\n    payload: InternalEventPayload,\n    onEvent: (event: InternalEventPayload) => void,\n  ): void {\n    // 1. Build event to send\n    const eventToSend = this.#buildEventToSend(context, payload, null);\n\n    // 2. Listen for replies in the background\n    this.#appToPluginEvents.listen((event: InternalEvent) => {\n      if (event.replyId === eventToSend.id) {\n        onEvent(event.payload);\n      }\n    });\n\n    // 3. Send the event after we start listening (to prevent race)\n    this.#sendEvent(eventToSend);\n  }\n\n  #newCtx(context: PluginContext): Context {\n    const _windowInfo = async () => {\n      if (context.label == null) {\n        throw new Error(\"Can't get window context without an active window\");\n      }\n      const payload: InternalEventPayload = {\n        type: \"window_info_request\",\n        label: context.label,\n      };\n\n      return this.#sendForReply<WindowInfoResponse>(context, payload);\n    };\n\n    return {\n      clipboard: {\n        copyText: async (text) => {\n          await this.#sendForReply(context, {\n            type: \"copy_text_request\",\n            text,\n          });\n        },\n      },\n      toast: {\n        show: async (args) => {\n          await this.#sendForReply(context, {\n            type: \"show_toast_request\",\n            // Handle default here because null/undefined both convert to None in Rust translation\n            timeout: args.timeout === undefined ? 5000 : args.timeout,\n            ...args,\n          });\n        },\n      },\n      window: {\n        requestId: async () => {\n          return (await _windowInfo()).requestId;\n        },\n        async workspaceId(): Promise<string | null> {\n          return (await _windowInfo()).workspaceId;\n        },\n        async environmentId(): Promise<string | null> {\n          return (await _windowInfo()).environmentId;\n        },\n        openUrl: async ({ onNavigate, onClose, ...args }) => {\n          args.label = args.label || `${Math.random()}`;\n          const payload: InternalEventPayload = { type: \"open_window_request\", ...args };\n          const onEvent = (event: InternalEventPayload) => {\n            if (event.type === \"window_navigate_event\") {\n              onNavigate?.(event);\n            } else if (event.type === \"window_close_event\") {\n              onClose?.();\n            }\n          };\n          this.#sendAndListenForEvents(context, payload, onEvent);\n          return {\n            close: () => {\n              const closePayload: InternalEventPayload = {\n                type: \"close_window_request\",\n                label: args.label,\n              };\n              this.#sendPayload(context, closePayload, null);\n            },\n          };\n        },\n        openExternalUrl: async (url) => {\n          await this.#sendForReply(context, {\n            type: \"open_external_url_request\",\n            url,\n          });\n        },\n      },\n      prompt: {\n        text: async (args) => {\n          const reply: PromptTextResponse = await this.#sendForReply(context, {\n            type: \"prompt_text_request\",\n            ...args,\n          });\n          return reply.value;\n        },\n        form: async (args) => {\n          // Resolve dynamic callbacks on initial inputs using default values\n          const defaults = applyFormInputDefaults(args.inputs, {});\n          const callArgs: CallPromptFormDynamicArgs = { values: defaults };\n          const resolvedInputs = await applyDynamicFormInput(\n            this.#newCtx(context),\n            args.inputs,\n            callArgs,\n          );\n          const strippedInputs = stripDynamicCallbacks(resolvedInputs);\n\n          // Build the event manually so we can get the event ID for keying\n          const eventToSend = this.#buildEventToSend(\n            context,\n            { type: \"prompt_form_request\", ...args, inputs: strippedInputs },\n            null,\n          );\n\n          // Store original inputs (with dynamic callbacks) for later resolution\n          this.#pendingDynamicForms.set(eventToSend.id, args.inputs);\n\n          const reply = await new Promise<PromptFormResponse>((resolve) => {\n            const cb = (event: InternalEvent) => {\n              if (event.replyId !== eventToSend.id) return;\n\n              if (event.payload.type === \"prompt_form_response\") {\n                const { done, values } = event.payload as PromptFormResponse;\n                if (done) {\n                  // Final response — resolve the promise and clean up\n                  this.#appToPluginEvents.unlisten(cb);\n                  this.#pendingDynamicForms.delete(eventToSend.id);\n                  resolve({ values } as PromptFormResponse);\n                } else {\n                  // Intermediate value change — resolve dynamic inputs and send back\n                  // Skip empty values (fired on initial mount before user interaction)\n                  const storedInputs = this.#pendingDynamicForms.get(eventToSend.id);\n                  if (storedInputs && values && Object.keys(values).length > 0) {\n                    const ctx = this.#newCtx(context);\n                    const callArgs: CallPromptFormDynamicArgs = { values };\n                    applyDynamicFormInput(ctx, storedInputs, callArgs)\n                      .then((resolvedInputs) => {\n                        const stripped = stripDynamicCallbacks(resolvedInputs);\n                        this.#sendPayload(\n                          context,\n                          { type: \"prompt_form_request\", ...args, inputs: stripped },\n                          eventToSend.id,\n                        );\n                      })\n                      .catch((err) => {\n                        console.error(\"Failed to resolve dynamic form inputs\", err);\n                      });\n                  }\n                }\n              }\n            };\n            this.#appToPluginEvents.listen(cb);\n\n            // Send the initial event after we start listening (to prevent race)\n            this.#sendEvent(eventToSend);\n          });\n\n          return reply.values;\n        },\n      },\n      httpResponse: {\n        find: async (args) => {\n          const payload = {\n            type: \"find_http_responses_request\",\n            ...args,\n          } as const;\n          const { httpResponses } = await this.#sendForReply<FindHttpResponsesResponse>(\n            context,\n            payload,\n          );\n          return httpResponses;\n        },\n      },\n      grpcRequest: {\n        render: async (args) => {\n          const payload = {\n            type: \"render_grpc_request_request\",\n            ...args,\n          } as const;\n          const { grpcRequest } = await this.#sendForReply<RenderGrpcRequestResponse>(\n            context,\n            payload,\n          );\n          return grpcRequest;\n        },\n      },\n      httpRequest: {\n        getById: async (args) => {\n          const payload = {\n            type: \"get_http_request_by_id_request\",\n            ...args,\n          } as const;\n          const { httpRequest } = await this.#sendForReply<GetHttpRequestByIdResponse>(\n            context,\n            payload,\n          );\n          return httpRequest;\n        },\n        send: async (args) => {\n          const payload = {\n            type: \"send_http_request_request\",\n            ...args,\n          } as const;\n          const { httpResponse } = await this.#sendForReply<SendHttpRequestResponse>(\n            context,\n            payload,\n          );\n          return httpResponse;\n        },\n        render: async (args) => {\n          const payload = {\n            type: \"render_http_request_request\",\n            ...args,\n          } as const;\n          const { httpRequest } = await this.#sendForReply<RenderHttpRequestResponse>(\n            context,\n            payload,\n          );\n          return httpRequest;\n        },\n        list: async (args?: { folderId?: string }) => {\n          const payload: InternalEventPayload = {\n            type: \"list_http_requests_request\",\n            folderId: args?.folderId,\n          } satisfies ListHttpRequestsRequest & { type: \"list_http_requests_request\" };\n          const { httpRequests } = await this.#sendForReply<ListHttpRequestsResponse>(\n            context,\n            payload,\n          );\n          return httpRequests;\n        },\n        create: async (args) => {\n          const payload = {\n            type: \"upsert_model_request\",\n            model: {\n              name: \"\",\n              method: \"GET\",\n              ...args,\n              id: \"\",\n              model: \"http_request\",\n            },\n          } as InternalEventPayload;\n          const response = await this.#sendForReply<UpsertModelResponse>(context, payload);\n          return response.model as HttpRequest;\n        },\n        update: async (args) => {\n          const payload = {\n            type: \"upsert_model_request\",\n            model: {\n              model: \"http_request\",\n              ...args,\n            },\n          } as InternalEventPayload;\n          const response = await this.#sendForReply<UpsertModelResponse>(context, payload);\n          return response.model as HttpRequest;\n        },\n        delete: async (args) => {\n          const payload = {\n            type: \"delete_model_request\",\n            model: \"http_request\",\n            id: args.id,\n          } as InternalEventPayload;\n          const response = await this.#sendForReply<DeleteModelResponse>(context, payload);\n          return response.model as HttpRequest;\n        },\n      },\n      folder: {\n        list: async () => {\n          const payload = { type: \"list_folders_request\" } as const;\n          const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);\n          return folders;\n        },\n        getById: async (args: { id: string }) => {\n          const payload = { type: \"list_folders_request\" } as const;\n          const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);\n          return folders.find((f) => f.id === args.id) ?? null;\n        },\n        create: async ({ name, ...args }) => {\n          const payload = {\n            type: \"upsert_model_request\",\n            model: {\n              ...args,\n              name: name ?? \"\",\n              id: \"\",\n              model: \"folder\",\n            },\n          } as InternalEventPayload;\n          const response = await this.#sendForReply<UpsertModelResponse>(context, payload);\n          return response.model as Folder;\n        },\n        update: async (args) => {\n          const payload = {\n            type: \"upsert_model_request\",\n            model: {\n              model: \"folder\",\n              ...args,\n            },\n          } as InternalEventPayload;\n          const response = await this.#sendForReply<UpsertModelResponse>(context, payload);\n          return response.model as Folder;\n        },\n        delete: async (args: { id: string }) => {\n          const payload = {\n            type: \"delete_model_request\",\n            model: \"folder\",\n            id: args.id,\n          } as InternalEventPayload;\n          const response = await this.#sendForReply<DeleteModelResponse>(context, payload);\n          return response.model as Folder;\n        },\n      },\n      cookies: {\n        getValue: async (args: GetCookieValueRequest) => {\n          const payload = {\n            type: \"get_cookie_value_request\",\n            ...args,\n          } as const;\n          const { value } = await this.#sendForReply<GetCookieValueResponse>(context, payload);\n          return value;\n        },\n        listNames: async () => {\n          const payload = { type: \"list_cookie_names_request\" } as const;\n          const { names } = await this.#sendForReply<ListCookieNamesResponse>(context, payload);\n          return names;\n        },\n      },\n      templates: {\n        /**\n         * Invoke Yaak's template engine to render a value. If the value is a nested type\n         * (eg. object), it will be recursively rendered.\n         */\n        render: async (args: TemplateRenderRequest) => {\n          const payload = { type: \"template_render_request\", ...args } as const;\n          const result = await this.#sendForReply<TemplateRenderResponse>(context, payload);\n          // oxlint-disable-next-line no-explicit-any -- That's okay\n          return result.data as any;\n        },\n      },\n      store: {\n        get: async <T>(key: string) => {\n          const payload = { type: \"get_key_value_request\", key } as const;\n          const result = await this.#sendForReply<GetKeyValueResponse>(context, payload);\n          return result.value ? (JSON.parse(result.value) as T) : undefined;\n        },\n        set: async <T>(key: string, value: T) => {\n          const valueStr = JSON.stringify(value);\n          const payload: InternalEventPayload = {\n            type: \"set_key_value_request\",\n            key,\n            value: valueStr,\n          };\n          await this.#sendForReply<GetKeyValueResponse>(context, payload);\n        },\n        delete: async (key: string) => {\n          const payload = { type: \"delete_key_value_request\", key } as const;\n          const result = await this.#sendForReply<DeleteKeyValueResponse>(context, payload);\n          return result.deleted;\n        },\n      },\n      plugin: {\n        reload: () => {\n          this.#sendPayload(context, { type: \"reload_response\", silent: true }, null);\n        },\n      },\n      workspace: {\n        list: async () => {\n          const payload = {\n            type: \"list_open_workspaces_request\",\n          } as InternalEventPayload;\n          const response = await this.#sendForReply<ListOpenWorkspacesResponse>(context, payload);\n          return response.workspaces.map((w) => {\n            // Internal workspace info includes label field not in public API\n            type WorkspaceInfoInternal = typeof w & { label?: string };\n            return {\n              id: w.id,\n              name: w.name,\n              // Hide label from plugin authors, but keep it for internal routing\n              _label: (w as WorkspaceInfoInternal).label as string,\n            };\n          });\n        },\n        withContext: (workspaceHandle: { id: string; name: string; _label?: string }) => {\n          // Create a new context with the workspace's window label\n          const newContext: PluginContext = {\n            ...context,\n            label: workspaceHandle._label || null,\n            workspaceId: workspaceHandle.id,\n          };\n          return this.#newCtx(newContext);\n        },\n      },\n    };\n  }\n}\n\nfunction stripDynamicCallbacks(inputs: { dynamic?: unknown }[]): FormInput[] {\n  return inputs.map((input) => {\n    // oxlint-disable-next-line no-explicit-any -- stripping dynamic from union type\n    const { dynamic: _dynamic, ...rest } = input as any;\n    if (\"inputs\" in rest && Array.isArray(rest.inputs)) {\n      rest.inputs = stripDynamicCallbacks(rest.inputs);\n    }\n    return rest as FormInput;\n  });\n}\n\nfunction genId(len = 5): string {\n  const alphabet = \"01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\";\n  let id = \"\";\n  for (let i = 0; i < len; i++) {\n    id += alphabet[Math.floor(Math.random() * alphabet.length)];\n  }\n  return id;\n}\n\nconst watchedFiles: Record<string, Stats | null> = {};\n\n/**\n * Watch a file and trigger a callback on change.\n *\n * We also track the stat for each file because fs.watch() will\n * trigger a \"change\" event when the access date changes.\n */\nfunction watchFile(filepath: string, cb: () => void) {\n  watch(filepath, () => {\n    const stat = statSync(filepath, { throwIfNoEntry: false });\n    if (stat == null || stat.mtimeMs !== watchedFiles[filepath]?.mtimeMs) {\n      watchedFiles[filepath] = stat ?? null;\n      console.log(\"[plugin-runtime] watchFile triggered\", filepath);\n      cb();\n    }\n  });\n}\n"
  },
  {
    "path": "packages/plugin-runtime/src/common.ts",
    "content": "import type {\n  CallPromptFormDynamicArgs,\n  Context,\n  DynamicAuthenticationArg,\n  DynamicPromptFormArg,\n  DynamicTemplateFunctionArg,\n} from \"@yaakapp/api\";\nimport type {\n  CallHttpAuthenticationActionArgs,\n  CallTemplateFunctionArgs,\n} from \"@yaakapp-internal/plugins\";\n\ntype AnyDynamicArg = DynamicTemplateFunctionArg | DynamicAuthenticationArg | DynamicPromptFormArg;\ntype AnyCallArgs =\n  | CallTemplateFunctionArgs\n  | CallHttpAuthenticationActionArgs\n  | CallPromptFormDynamicArgs;\n\nexport async function applyDynamicFormInput(\n  ctx: Context,\n  args: DynamicTemplateFunctionArg[],\n  callArgs: CallTemplateFunctionArgs,\n): Promise<DynamicTemplateFunctionArg[]>;\n\nexport async function applyDynamicFormInput(\n  ctx: Context,\n  args: DynamicAuthenticationArg[],\n  callArgs: CallHttpAuthenticationActionArgs,\n): Promise<DynamicAuthenticationArg[]>;\n\nexport async function applyDynamicFormInput(\n  ctx: Context,\n  args: DynamicPromptFormArg[],\n  callArgs: CallPromptFormDynamicArgs,\n): Promise<DynamicPromptFormArg[]>;\n\nexport async function applyDynamicFormInput(\n  ctx: Context,\n  args: AnyDynamicArg[],\n  callArgs: AnyCallArgs,\n): Promise<AnyDynamicArg[]> {\n  const resolvedArgs: AnyDynamicArg[] = [];\n  for (const { dynamic, ...arg } of args) {\n    const dynamicResult =\n      typeof dynamic === \"function\"\n        ? await dynamic(\n            ctx,\n            callArgs as CallTemplateFunctionArgs &\n              CallHttpAuthenticationActionArgs &\n              CallPromptFormDynamicArgs,\n          )\n        : undefined;\n\n    const newArg = {\n      ...arg,\n      ...dynamicResult,\n    } as AnyDynamicArg;\n\n    if (\"inputs\" in newArg && Array.isArray(newArg.inputs)) {\n      try {\n        newArg.inputs = await applyDynamicFormInput(\n          ctx,\n          newArg.inputs as DynamicTemplateFunctionArg[],\n          callArgs as CallTemplateFunctionArgs &\n            CallHttpAuthenticationActionArgs &\n            CallPromptFormDynamicArgs,\n        );\n      } catch (e) {\n        console.error(\"Failed to apply dynamic form input\", e);\n      }\n    }\n    resolvedArgs.push(newArg);\n  }\n  return resolvedArgs;\n}\n"
  },
  {
    "path": "packages/plugin-runtime/src/index.ts",
    "content": "import type { InternalEvent } from \"@yaakapp/api\";\nimport WebSocket from \"ws\";\nimport { EventChannel } from \"./EventChannel\";\nimport { PluginHandle } from \"./PluginHandle\";\n\nconst port = process.env.PORT;\nif (!port) {\n  throw new Error(\"Plugin runtime missing PORT\");\n}\n\nconst host = process.env.HOST;\nif (!host) {\n  throw new Error(\"Plugin runtime missing HOST\");\n}\n\nconst pluginToAppEvents = new EventChannel();\nconst plugins: Record<string, PluginHandle> = {};\n\nconst ws = new WebSocket(`ws://${host}:${port}`);\n\nws.on(\"message\", async (e: Buffer) => {\n  try {\n    await handleIncoming(e.toString());\n  } catch (err) {\n    console.log(\"Failed to handle incoming plugin event\", err);\n  }\n});\nws.on(\"open\", () => console.log(\"Plugin runtime connected to websocket\"));\nws.on(\"error\", (err: unknown) => console.error(\"Plugin runtime websocket error\", err));\nws.on(\"close\", (code: number) => console.log(\"Plugin runtime websocket closed\", code));\n\n// Listen for incoming events from plugins\npluginToAppEvents.listen((e) => {\n  const eventStr = JSON.stringify(e);\n  ws.send(eventStr);\n});\n\nasync function handleIncoming(msg: string) {\n  const pluginEvent: InternalEvent = JSON.parse(msg);\n  // Handle special event to bootstrap plugin\n  if (pluginEvent.payload.type === \"boot_request\") {\n    const plugin = new PluginHandle(\n      pluginEvent.pluginRefId,\n      pluginEvent.context,\n      pluginEvent.payload,\n      pluginToAppEvents,\n    );\n    plugins[pluginEvent.pluginRefId] = plugin;\n  }\n\n  // Once booted, forward all events to the plugin worker\n  const plugin = plugins[pluginEvent.pluginRefId];\n  if (!plugin) {\n    console.warn(\"Failed to get plugin for event by\", pluginEvent.pluginRefId);\n    return;\n  }\n\n  if (pluginEvent.payload.type === \"terminate_request\") {\n    await plugin.terminate();\n    console.log(\"Terminated plugin worker\", pluginEvent.pluginRefId);\n    delete plugins[pluginEvent.pluginRefId];\n  }\n\n  plugin.sendToWorker(pluginEvent);\n}\n\nprocess.on(\"unhandledRejection\", (reason, promise) => {\n  console.error(\"Unhandled Rejection at:\", promise, \"reason:\", reason);\n});\n\nprocess.on(\"uncaughtException\", (error) => {\n  console.error(\"Uncaught Exception:\", error);\n});\n"
  },
  {
    "path": "packages/plugin-runtime/src/interceptStdout.ts",
    "content": "/* oxlint-disable unbound-method */\nimport process from \"node:process\";\n\nexport function interceptStdout(intercept: (text: string) => string) {\n  const old_stdout_write = process.stdout.write;\n  const old_stderr_write = process.stderr.write;\n\n  process.stdout.write = ((write) =>\n    ((text: string, ...args: never[]) => {\n      write.call(process.stdout, interceptor(text, intercept), ...args);\n      return true;\n    }) as typeof process.stdout.write)(process.stdout.write);\n\n  process.stderr.write = ((write) =>\n    ((text: string, ...args: never[]) => {\n      write.call(process.stderr, interceptor(text, intercept), ...args);\n      return true;\n    }) as typeof process.stderr.write)(process.stderr.write);\n\n  // puts back to original\n  return function unhook() {\n    process.stdout.write = old_stdout_write;\n    process.stderr.write = old_stderr_write;\n  };\n}\n\nfunction interceptor(text: string, fn: (text: string) => string) {\n  return fn(text).replace(/\\n$/, \"\") + (fn(text) && text.endsWith(\"\\n\") ? \"\\n\" : \"\");\n}\n"
  },
  {
    "path": "packages/plugin-runtime/src/migrations.ts",
    "content": "import type { TemplateFunctionPlugin } from \"@yaakapp/api\";\n\nexport function migrateTemplateFunctionSelectOptions(\n  f: TemplateFunctionPlugin,\n): TemplateFunctionPlugin {\n  const migratedArgs = f.args.map((a) => {\n    if (a.type === \"select\") {\n      // Migrate old options that had 'name' instead of 'label'\n      type LegacyOption = { label?: string; value: string; name?: string };\n      a.options = a.options.map((o) => {\n        const legacy = o as LegacyOption;\n        return {\n          label: legacy.label ?? legacy.name ?? \"\",\n          value: legacy.value,\n        };\n      });\n    }\n    return a;\n  });\n\n  return { ...f, args: migratedArgs };\n}\n"
  },
  {
    "path": "packages/plugin-runtime/tests/common.test.ts",
    "content": "import { applyFormInputDefaults } from \"@yaakapp-internal/lib/templateFunction\";\nimport type { CallTemplateFunctionArgs } from \"@yaakapp-internal/plugins\";\nimport type { Context, DynamicTemplateFunctionArg } from \"@yaakapp/api\";\nimport { describe, expect, test } from \"vite-plus/test\";\nimport { applyDynamicFormInput } from \"../src/common\";\n\ndescribe(\"applyFormInputDefaults\", () => {\n  test(\"Works with top-level select\", () => {\n    const args: DynamicTemplateFunctionArg[] = [\n      {\n        type: \"select\",\n        name: \"test\",\n        options: [{ label: \"Option 1\", value: \"one\" }],\n        defaultValue: \"one\",\n      },\n    ];\n    expect(applyFormInputDefaults(args, {})).toEqual({\n      test: \"one\",\n    });\n  });\n\n  test(\"Works with existing value\", () => {\n    const args: DynamicTemplateFunctionArg[] = [\n      {\n        type: \"select\",\n        name: \"test\",\n        options: [{ label: \"Option 1\", value: \"one\" }],\n        defaultValue: \"one\",\n      },\n    ];\n    expect(applyFormInputDefaults(args, { test: \"explicit\" })).toEqual({\n      test: \"explicit\",\n    });\n  });\n\n  test(\"Works with recursive select\", () => {\n    const args: DynamicTemplateFunctionArg[] = [\n      { type: \"text\", name: \"dummy\", defaultValue: \"top\" },\n      {\n        type: \"accordion\",\n        label: \"Test\",\n        inputs: [\n          { type: \"text\", name: \"name\", defaultValue: \"hello\" },\n          {\n            type: \"select\",\n            name: \"test\",\n            options: [{ label: \"Option 1\", value: \"one\" }],\n            defaultValue: \"one\",\n          },\n        ],\n      },\n    ];\n    expect(applyFormInputDefaults(args, {})).toEqual({\n      dummy: \"top\",\n      test: \"one\",\n      name: \"hello\",\n    });\n  });\n\n  test(\"Works with dynamic options\", () => {\n    const args: DynamicTemplateFunctionArg[] = [\n      {\n        type: \"select\",\n        name: \"test\",\n        defaultValue: \"one\",\n        options: [],\n        dynamic() {\n          return { options: [{ label: \"Option 1\", value: \"one\" }] };\n        },\n      },\n    ];\n    expect(applyFormInputDefaults(args, {})).toEqual({\n      test: \"one\",\n    });\n    expect(applyFormInputDefaults(args, {})).toEqual({\n      test: \"one\",\n    });\n  });\n});\n\ndescribe(\"applyDynamicFormInput\", () => {\n  test(\"Works with plain input\", async () => {\n    const ctx = {} as Context;\n    const args: DynamicTemplateFunctionArg[] = [\n      { type: \"text\", name: \"name\" },\n      { type: \"checkbox\", name: \"checked\" },\n    ];\n    const callArgs: CallTemplateFunctionArgs = {\n      values: {},\n      purpose: \"preview\",\n    };\n    expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([\n      { type: \"text\", name: \"name\" },\n      { type: \"checkbox\", name: \"checked\" },\n    ]);\n  });\n\n  test(\"Works with dynamic input\", async () => {\n    const ctx = {} as Context;\n    const args: DynamicTemplateFunctionArg[] = [\n      {\n        type: \"text\",\n        name: \"name\",\n        async dynamic(_ctx, _args) {\n          return { hidden: true };\n        },\n      },\n    ];\n    const callArgs: CallTemplateFunctionArgs = {\n      values: {},\n      purpose: \"preview\",\n    };\n    expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([\n      { type: \"text\", name: \"name\", hidden: true },\n    ]);\n  });\n\n  test(\"Works with recursive dynamic input\", async () => {\n    const ctx = {} as Context;\n    const callArgs: CallTemplateFunctionArgs = {\n      values: { hello: \"world\" },\n      purpose: \"preview\",\n    };\n    const args: DynamicTemplateFunctionArg[] = [\n      {\n        type: \"banner\",\n        inputs: [\n          {\n            type: \"text\",\n            name: \"name\",\n            async dynamic(_ctx, args) {\n              return { hidden: args.values.hello === \"world\" };\n            },\n          },\n        ],\n      },\n    ];\n    expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([\n      {\n        type: \"banner\",\n        inputs: [\n          {\n            type: \"text\",\n            name: \"name\",\n            hidden: true,\n          },\n        ],\n      },\n    ]);\n  });\n});\n"
  },
  {
    "path": "packages/plugin-runtime/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"module\": \"node16\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"target\": \"es2021\",\n    \"lib\": [\"es2021\"],\n    \"noImplicitAny\": false,\n    \"moduleResolution\": \"node16\",\n    \"resolveJsonModule\": true,\n    \"sourceMap\": true,\n    \"outDir\": \"build\"\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "packages/plugin-runtime-types/.gitignore",
    "content": "lib\nnode_modules\n"
  },
  {
    "path": "packages/plugin-runtime-types/README.md",
    "content": "# Yaak Plugin API\n\nYaak is a desktop [API client](https://yaak.app/blog/yet-another-api-client) for\ninteracting with REST, GraphQL, Server Sent Events (SSE), WebSocket, and gRPC APIs. It's\nbuilt using Tauri, Rust, and ReactJS.\n\nPlugins can be created in TypeScript, which are executed alongside Yaak in a NodeJS\nruntime. This package contains the TypeScript type definitions required to make building\nYaak plugins a breeze.\n\n## Quick Start\n\nThe easiest way to get started is by generating a plugin with the Yaak CLI:\n\n```shell\nnpx @yaakapp/cli generate\n```\n\nFor more details on creating plugins, check out\nthe [Quick Start Guide](https://yaak.app/docs/plugin-development/plugins-quick-start)\n\n## Installation\n\nIf you prefer starting from scratch, manually install the types package:\n\n```shell\nnpm install -D @yaakapp/api\n```\n"
  },
  {
    "path": "packages/plugin-runtime-types/package.json",
    "content": "{\n  \"name\": \"@yaakapp/api\",\n  \"version\": \"0.8.0\",\n  \"keywords\": [\n    \"api-client\",\n    \"bruno-alternative\",\n    \"insomnia-alternative\",\n    \"postman-alternative\"\n  ],\n  \"homepage\": \"https://yaak.app\",\n  \"bugs\": {\n    \"url\": \"https://feedback.yaak.app\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak\"\n  },\n  \"files\": [\n    \"lib/**/*\"\n  ],\n  \"main\": \"lib/index.js\",\n  \"typings\": \"./lib/index.d.ts\",\n  \"scripts\": {\n    \"bootstrap\": \"npm run build\",\n    \"build\": \"run-s build:copy-types build:tsc\",\n    \"build:tsc\": \"tsc\",\n    \"build:copy-types\": \"run-p build:copy-types:*\",\n    \"build:copy-types:root\": \"cpy --flat ../../crates/yaak-plugins/bindings/*.ts ./src/bindings\",\n    \"build:copy-types:next\": \"cpy --flat ../../crates/yaak-plugins/bindings/serde_json/*.ts ./src/bindings/serde_json\",\n    \"prepublishOnly\": \"npm run build\"\n  },\n  \"dependencies\": {\n    \"@types/node\": \"^24.0.13\"\n  },\n  \"devDependencies\": {\n    \"cpy-cli\": \"^5.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/bindings/gen_api.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\nimport type { PluginVersion } from \"./gen_search\";\n\nexport type PluginNameVersion = { name: string, version: string, };\n\nexport type PluginSearchResponse = { plugins: Array<PluginVersion>, };\n\nexport type PluginUpdatesResponse = { plugins: Array<PluginNameVersion>, };\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/bindings/gen_events.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\nimport type { AnyModel, Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest, Workspace } from \"./gen_models\";\nimport type { JsonValue } from \"./serde_json/JsonValue\";\n\nexport type BootRequest = { dir: string, watch: boolean, };\n\nexport type CallFolderActionArgs = { folder: Folder, };\n\nexport type CallFolderActionRequest = { index: number, pluginRefId: string, args: CallFolderActionArgs, };\n\nexport type CallGrpcRequestActionArgs = { grpcRequest: GrpcRequest, protoFiles: Array<string>, };\n\nexport type CallGrpcRequestActionRequest = { index: number, pluginRefId: string, args: CallGrpcRequestActionArgs, };\n\nexport type CallHttpAuthenticationActionArgs = { contextId: string, values: { [key in string]?: JsonPrimitive }, };\n\nexport type CallHttpAuthenticationActionRequest = { index: number, pluginRefId: string, args: CallHttpAuthenticationActionArgs, };\n\nexport type CallHttpAuthenticationRequest = { contextId: string, values: { [key in string]?: JsonPrimitive }, method: string, url: string, headers: Array<HttpHeader>, };\n\nexport type CallHttpAuthenticationResponse = {\n/**\n * HTTP headers to add to the request. Existing headers will be replaced, while\n * new headers will be added.\n */\nsetHeaders?: Array<HttpHeader>,\n/**\n * Query parameters to add to the request. Existing params will be replaced, while\n * new params will be added.\n */\nsetQueryParameters?: Array<HttpHeader>, };\n\nexport type CallHttpRequestActionArgs = { httpRequest: HttpRequest, };\n\nexport type CallHttpRequestActionRequest = { index: number, pluginRefId: string, args: CallHttpRequestActionArgs, };\n\nexport type CallTemplateFunctionArgs = { purpose: RenderPurpose, values: { [key in string]?: JsonPrimitive }, };\n\nexport type CallTemplateFunctionRequest = { name: string, args: CallTemplateFunctionArgs, };\n\nexport type CallTemplateFunctionResponse = { value: string | null, error?: string, };\n\nexport type CallWebsocketRequestActionArgs = { websocketRequest: WebsocketRequest, };\n\nexport type CallWebsocketRequestActionRequest = { index: number, pluginRefId: string, args: CallWebsocketRequestActionArgs, };\n\nexport type CallWorkspaceActionArgs = { workspace: Workspace, };\n\nexport type CallWorkspaceActionRequest = { index: number, pluginRefId: string, args: CallWorkspaceActionArgs, };\n\nexport type CloseWindowRequest = { label: string, };\n\nexport type Color = \"primary\" | \"secondary\" | \"info\" | \"success\" | \"notice\" | \"warning\" | \"danger\";\n\nexport type CompletionOptionType = \"constant\" | \"variable\";\n\nexport type Content = { \"type\": \"text\", content: string, } | { \"type\": \"markdown\", content: string, };\n\nexport type CopyTextRequest = { text: string, };\n\nexport type DeleteKeyValueRequest = { key: string, };\n\nexport type DeleteKeyValueResponse = { deleted: boolean, };\n\nexport type DeleteModelRequest = { model: string, id: string, };\n\nexport type DeleteModelResponse = { model: AnyModel, };\n\nexport type DialogSize = \"sm\" | \"md\" | \"lg\" | \"full\" | \"dynamic\";\n\nexport type EditorLanguage = \"text\" | \"javascript\" | \"json\" | \"html\" | \"xml\" | \"graphql\" | \"markdown\" | \"c\" | \"clojure\" | \"csharp\" | \"go\" | \"http\" | \"java\" | \"kotlin\" | \"objective_c\" | \"ocaml\" | \"php\" | \"powershell\" | \"python\" | \"r\" | \"ruby\" | \"shell\" | \"swift\";\n\nexport type EmptyPayload = {};\n\nexport type ErrorResponse = { error: string, };\n\nexport type ExportHttpRequestRequest = { httpRequest: HttpRequest, };\n\nexport type ExportHttpRequestResponse = { content: string, };\n\nexport type FileFilter = { name: string,\n/**\n * File extensions to require\n */\nextensions: Array<string>, };\n\nexport type FilterRequest = { content: string, filter: string, };\n\nexport type FilterResponse = { content: string, error?: string, };\n\nexport type FindHttpResponsesRequest = { requestId: string, limit?: number, };\n\nexport type FindHttpResponsesResponse = { httpResponses: Array<HttpResponse>, };\n\nexport type FolderAction = { label: string, icon?: Icon, };\n\nexport type FormInput = { \"type\": \"text\" } & FormInputText | { \"type\": \"editor\" } & FormInputEditor | { \"type\": \"select\" } & FormInputSelect | { \"type\": \"checkbox\" } & FormInputCheckbox | { \"type\": \"file\" } & FormInputFile | { \"type\": \"http_request\" } & FormInputHttpRequest | { \"type\": \"accordion\" } & FormInputAccordion | { \"type\": \"h_stack\" } & FormInputHStack | { \"type\": \"banner\" } & FormInputBanner | { \"type\": \"markdown\" } & FormInputMarkdown | { \"type\": \"key_value\" } & FormInputKeyValue;\n\nexport type FormInputAccordion = { label: string, inputs?: Array<FormInput>, hidden?: boolean, };\n\nexport type FormInputBanner = { inputs?: Array<FormInput>, hidden?: boolean, color?: Color, };\n\nexport type FormInputBase = {\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type FormInputCheckbox = {\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type FormInputEditor = {\n/**\n * Placeholder for the text input\n */\nplaceholder?: string | null,\n/**\n * Don't show the editor gutter (line numbers, folds, etc.)\n */\nhideGutter?: boolean,\n/**\n * Language for syntax highlighting\n */\nlanguage?: EditorLanguage, readOnly?: boolean,\n/**\n * Fixed number of visible rows\n */\nrows?: number, completionOptions?: Array<GenericCompletionOption>,\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type FormInputFile = {\n/**\n * The title of the file selection window\n */\ntitle: string,\n/**\n * Allow selecting multiple files\n */\nmultiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array<FileFilter>,\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type FormInputHStack = { inputs?: Array<FormInput>, hidden?: boolean, };\n\nexport type FormInputHttpRequest = {\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type FormInputKeyValue = {\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type FormInputMarkdown = { content: string, hidden?: boolean, };\n\nexport type FormInputSelect = {\n/**\n * The options that will be available in the select input\n */\noptions: Array<FormInputSelectOption>,\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type FormInputSelectOption = { label: string, value: string, };\n\nexport type FormInputText = {\n/**\n * Placeholder for the text input\n */\nplaceholder?: string | null,\n/**\n * Placeholder for the text input\n */\npassword?: boolean,\n/**\n * Whether to allow newlines in the input, like a <textarea/>\n */\nmultiLine?: boolean, completionOptions?: Array<GenericCompletionOption>,\n/**\n * The name of the input. The value will be stored at this object attribute in the resulting data\n */\nname: string,\n/**\n * Whether this input is visible for the given configuration. Use this to\n * make branching forms.\n */\nhidden?: boolean,\n/**\n * Whether the user must fill in the argument\n */\noptional?: boolean,\n/**\n * The label of the input\n */\nlabel?: string,\n/**\n * Visually hide the label of the input\n */\nhideLabel?: boolean,\n/**\n * The default value\n */\ndefaultValue?: string, disabled?: boolean,\n/**\n * Longer description of the input, likely shown in a tooltip\n */\ndescription?: string, };\n\nexport type GenericCompletionOption = { label: string, detail?: string, info?: string, type?: CompletionOptionType, boost?: number, };\n\nexport type GetCookieValueRequest = { name: string, };\n\nexport type GetCookieValueResponse = { value: string | null, };\n\nexport type GetFolderActionsResponse = { actions: Array<FolderAction>, pluginRefId: string, };\n\nexport type GetGrpcRequestActionsResponse = { actions: Array<GrpcRequestAction>, pluginRefId: string, };\n\nexport type GetHttpAuthenticationConfigRequest = { contextId: string, values: { [key in string]?: JsonPrimitive }, };\n\nexport type GetHttpAuthenticationConfigResponse = { args: Array<FormInput>, pluginRefId: string, actions?: Array<HttpAuthenticationAction>, };\n\nexport type GetHttpAuthenticationSummaryResponse = { name: string, label: string, shortLabel: string, };\n\nexport type GetHttpRequestActionsResponse = { actions: Array<HttpRequestAction>, pluginRefId: string, };\n\nexport type GetHttpRequestByIdRequest = { id: string, };\n\nexport type GetHttpRequestByIdResponse = { httpRequest: HttpRequest | null, };\n\nexport type GetKeyValueRequest = { key: string, };\n\nexport type GetKeyValueResponse = { value?: string, };\n\nexport type GetTemplateFunctionConfigRequest = { contextId: string, name: string, values: { [key in string]?: JsonPrimitive }, };\n\nexport type GetTemplateFunctionConfigResponse = { function: TemplateFunction, pluginRefId: string, };\n\nexport type GetTemplateFunctionSummaryResponse = { functions: Array<TemplateFunction>, pluginRefId: string, };\n\nexport type GetThemesRequest = Record<string, never>;\n\nexport type GetThemesResponse = { themes: Array<Theme>, };\n\nexport type GetWebsocketRequestActionsResponse = { actions: Array<WebsocketRequestAction>, pluginRefId: string, };\n\nexport type GetWorkspaceActionsResponse = { actions: Array<WorkspaceAction>, pluginRefId: string, };\n\nexport type GrpcRequestAction = { label: string, icon?: Icon, };\n\nexport type HttpAuthenticationAction = { label: string, icon?: Icon, };\n\nexport type HttpHeader = { name: string, value: string, };\n\nexport type HttpRequestAction = { label: string, icon?: Icon, };\n\nexport type Icon = \"alert_triangle\" | \"check\" | \"check_circle\" | \"chevron_down\" | \"copy\" | \"info\" | \"pin\" | \"search\" | \"trash\" | \"_unknown\";\n\nexport type ImportRequest = { content: string, };\n\nexport type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };\n\nexport type ImportResponse = { resources: ImportResources, };\n\nexport type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };\n\nexport type InternalEventPayload = { \"type\": \"boot_request\" } & BootRequest | { \"type\": \"boot_response\" } | { \"type\": \"reload_response\" } & ReloadResponse | { \"type\": \"terminate_request\" } | { \"type\": \"terminate_response\" } | { \"type\": \"import_request\" } & ImportRequest | { \"type\": \"import_response\" } & ImportResponse | { \"type\": \"filter_request\" } & FilterRequest | { \"type\": \"filter_response\" } & FilterResponse | { \"type\": \"export_http_request_request\" } & ExportHttpRequestRequest | { \"type\": \"export_http_request_response\" } & ExportHttpRequestResponse | { \"type\": \"send_http_request_request\" } & SendHttpRequestRequest | { \"type\": \"send_http_request_response\" } & SendHttpRequestResponse | { \"type\": \"list_cookie_names_request\" } & ListCookieNamesRequest | { \"type\": \"list_cookie_names_response\" } & ListCookieNamesResponse | { \"type\": \"get_cookie_value_request\" } & GetCookieValueRequest | { \"type\": \"get_cookie_value_response\" } & GetCookieValueResponse | { \"type\": \"get_http_request_actions_request\" } & EmptyPayload | { \"type\": \"get_http_request_actions_response\" } & GetHttpRequestActionsResponse | { \"type\": \"call_http_request_action_request\" } & CallHttpRequestActionRequest | { \"type\": \"get_websocket_request_actions_request\" } & EmptyPayload | { \"type\": \"get_websocket_request_actions_response\" } & GetWebsocketRequestActionsResponse | { \"type\": \"call_websocket_request_action_request\" } & CallWebsocketRequestActionRequest | { \"type\": \"get_workspace_actions_request\" } & EmptyPayload | { \"type\": \"get_workspace_actions_response\" } & GetWorkspaceActionsResponse | { \"type\": \"call_workspace_action_request\" } & CallWorkspaceActionRequest | { \"type\": \"get_folder_actions_request\" } & EmptyPayload | { \"type\": \"get_folder_actions_response\" } & GetFolderActionsResponse | { \"type\": \"call_folder_action_request\" } & CallFolderActionRequest | { \"type\": \"get_grpc_request_actions_request\" } & EmptyPayload | { \"type\": \"get_grpc_request_actions_response\" } & GetGrpcRequestActionsResponse | { \"type\": \"call_grpc_request_action_request\" } & CallGrpcRequestActionRequest | { \"type\": \"get_template_function_summary_request\" } & EmptyPayload | { \"type\": \"get_template_function_summary_response\" } & GetTemplateFunctionSummaryResponse | { \"type\": \"get_template_function_config_request\" } & GetTemplateFunctionConfigRequest | { \"type\": \"get_template_function_config_response\" } & GetTemplateFunctionConfigResponse | { \"type\": \"call_template_function_request\" } & CallTemplateFunctionRequest | { \"type\": \"call_template_function_response\" } & CallTemplateFunctionResponse | { \"type\": \"get_http_authentication_summary_request\" } & EmptyPayload | { \"type\": \"get_http_authentication_summary_response\" } & GetHttpAuthenticationSummaryResponse | { \"type\": \"get_http_authentication_config_request\" } & GetHttpAuthenticationConfigRequest | { \"type\": \"get_http_authentication_config_response\" } & GetHttpAuthenticationConfigResponse | { \"type\": \"call_http_authentication_request\" } & CallHttpAuthenticationRequest | { \"type\": \"call_http_authentication_response\" } & CallHttpAuthenticationResponse | { \"type\": \"call_http_authentication_action_request\" } & CallHttpAuthenticationActionRequest | { \"type\": \"call_http_authentication_action_response\" } & EmptyPayload | { \"type\": \"copy_text_request\" } & CopyTextRequest | { \"type\": \"copy_text_response\" } & EmptyPayload | { \"type\": \"render_http_request_request\" } & RenderHttpRequestRequest | { \"type\": \"render_http_request_response\" } & RenderHttpRequestResponse | { \"type\": \"render_grpc_request_request\" } & RenderGrpcRequestRequest | { \"type\": \"render_grpc_request_response\" } & RenderGrpcRequestResponse | { \"type\": \"template_render_request\" } & TemplateRenderRequest | { \"type\": \"template_render_response\" } & TemplateRenderResponse | { \"type\": \"get_key_value_request\" } & GetKeyValueRequest | { \"type\": \"get_key_value_response\" } & GetKeyValueResponse | { \"type\": \"set_key_value_request\" } & SetKeyValueRequest | { \"type\": \"set_key_value_response\" } & SetKeyValueResponse | { \"type\": \"delete_key_value_request\" } & DeleteKeyValueRequest | { \"type\": \"delete_key_value_response\" } & DeleteKeyValueResponse | { \"type\": \"open_window_request\" } & OpenWindowRequest | { \"type\": \"window_navigate_event\" } & WindowNavigateEvent | { \"type\": \"window_close_event\" } | { \"type\": \"close_window_request\" } & CloseWindowRequest | { \"type\": \"open_external_url_request\" } & OpenExternalUrlRequest | { \"type\": \"open_external_url_response\" } & EmptyPayload | { \"type\": \"show_toast_request\" } & ShowToastRequest | { \"type\": \"show_toast_response\" } & EmptyPayload | { \"type\": \"prompt_text_request\" } & PromptTextRequest | { \"type\": \"prompt_text_response\" } & PromptTextResponse | { \"type\": \"prompt_form_request\" } & PromptFormRequest | { \"type\": \"prompt_form_response\" } & PromptFormResponse | { \"type\": \"window_info_request\" } & WindowInfoRequest | { \"type\": \"window_info_response\" } & WindowInfoResponse | { \"type\": \"list_open_workspaces_request\" } & ListOpenWorkspacesRequest | { \"type\": \"list_open_workspaces_response\" } & ListOpenWorkspacesResponse | { \"type\": \"get_http_request_by_id_request\" } & GetHttpRequestByIdRequest | { \"type\": \"get_http_request_by_id_response\" } & GetHttpRequestByIdResponse | { \"type\": \"find_http_responses_request\" } & FindHttpResponsesRequest | { \"type\": \"find_http_responses_response\" } & FindHttpResponsesResponse | { \"type\": \"list_http_requests_request\" } & ListHttpRequestsRequest | { \"type\": \"list_http_requests_response\" } & ListHttpRequestsResponse | { \"type\": \"list_folders_request\" } & ListFoldersRequest | { \"type\": \"list_folders_response\" } & ListFoldersResponse | { \"type\": \"upsert_model_request\" } & UpsertModelRequest | { \"type\": \"upsert_model_response\" } & UpsertModelResponse | { \"type\": \"delete_model_request\" } & DeleteModelRequest | { \"type\": \"delete_model_response\" } & DeleteModelResponse | { \"type\": \"get_themes_request\" } & GetThemesRequest | { \"type\": \"get_themes_response\" } & GetThemesResponse | { \"type\": \"empty_response\" } & EmptyPayload | { \"type\": \"error_response\" } & ErrorResponse;\n\nexport type JsonPrimitive = string | number | boolean | null;\n\nexport type ListCookieNamesRequest = {};\n\nexport type ListCookieNamesResponse = { names: Array<string>, };\n\nexport type ListFoldersRequest = {};\n\nexport type ListFoldersResponse = { folders: Array<Folder>, };\n\nexport type ListHttpRequestsRequest = { folderId?: string, };\n\nexport type ListHttpRequestsResponse = { httpRequests: Array<HttpRequest>, };\n\nexport type ListOpenWorkspacesRequest = Record<string, never>;\n\nexport type ListOpenWorkspacesResponse = { workspaces: Array<WorkspaceInfo>, };\n\nexport type OpenExternalUrlRequest = { url: string, };\n\nexport type OpenWindowRequest = { url: string,\n/**\n * Label for the window. If not provided, a random one will be generated.\n */\nlabel: string, title?: string, size?: WindowSize, dataDirKey?: string, };\n\nexport type PluginContext = { id: string, label: string | null, workspaceId: string | null, };\n\nexport type PromptFormRequest = { id: string, title: string, description?: string, inputs: Array<FormInput>, confirmText?: string, cancelText?: string, size?: DialogSize, };\n\nexport type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };\n\nexport type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,\n/**\n * Text to add to the confirmation button\n */\nconfirmText?: string, password?: boolean,\n/**\n * Text to add to the cancel button\n */\ncancelText?: string,\n/**\n * Require the user to enter a non-empty value\n */\nrequired?: boolean, };\n\nexport type PromptTextResponse = { value: string | null, };\n\nexport type ReloadResponse = { silent: boolean, };\n\nexport type RenderGrpcRequestRequest = { grpcRequest: GrpcRequest, purpose: RenderPurpose, };\n\nexport type RenderGrpcRequestResponse = { grpcRequest: GrpcRequest, };\n\nexport type RenderHttpRequestRequest = { httpRequest: HttpRequest, purpose: RenderPurpose, };\n\nexport type RenderHttpRequestResponse = { httpRequest: HttpRequest, };\n\nexport type RenderPurpose = \"send\" | \"preview\";\n\nexport type SendHttpRequestRequest = { httpRequest: Partial<HttpRequest>, };\n\nexport type SendHttpRequestResponse = { httpResponse: HttpResponse, };\n\nexport type SetKeyValueRequest = { key: string, value: string, };\n\nexport type SetKeyValueResponse = {};\n\nexport type ShowToastRequest = { message: string, color?: Color, icon?: Icon, timeout?: number, };\n\nexport type TemplateFunction = { name: string, previewType?: TemplateFunctionPreviewType, description?: string,\n/**\n * Also support alternative names. This is useful for not breaking existing\n * tags when changing the `name` property\n */\naliases?: Array<string>, args: Array<TemplateFunctionArg>,\n/**\n * A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons).\n */\npreviewArgs?: Array<string>, };\n\n/**\n * Similar to FormInput, but contains\n */\nexport type TemplateFunctionArg = FormInput;\n\nexport type TemplateFunctionPreviewType = \"live\" | \"click\" | \"none\";\n\nexport type TemplateRenderRequest = { data: JsonValue, purpose: RenderPurpose, };\n\nexport type TemplateRenderResponse = { data: JsonValue, };\n\nexport type Theme = {\n/**\n * How the theme is identified. This should never be changed\n */\nid: string,\n/**\n * The friendly name of the theme to be displayed to the user\n */\nlabel: string,\n/**\n * Whether the theme will be used for dark or light appearance\n */\ndark: boolean,\n/**\n * The default top-level colors for the theme\n */\nbase: ThemeComponentColors,\n/**\n * Optionally override theme for individual UI components for more control\n */\ncomponents?: ThemeComponents, };\n\nexport type ThemeComponentColors = { surface?: string, surfaceHighlight?: string, surfaceActive?: string, text?: string, textSubtle?: string, textSubtlest?: string, border?: string, borderSubtle?: string, borderFocus?: string, shadow?: string, backdrop?: string, selection?: string, primary?: string, secondary?: string, info?: string, success?: string, notice?: string, warning?: string, danger?: string, };\n\nexport type ThemeComponents = { dialog?: ThemeComponentColors, menu?: ThemeComponentColors, toast?: ThemeComponentColors, sidebar?: ThemeComponentColors, responsePane?: ThemeComponentColors, appHeader?: ThemeComponentColors, button?: ThemeComponentColors, banner?: ThemeComponentColors, templateTag?: ThemeComponentColors, urlBar?: ThemeComponentColors, editor?: ThemeComponentColors, input?: ThemeComponentColors, };\n\nexport type UpsertModelRequest = { model: AnyModel, };\n\nexport type UpsertModelResponse = { model: AnyModel, };\n\nexport type WebsocketRequestAction = { label: string, icon?: Icon, };\n\nexport type WindowInfoRequest = { label: string, };\n\nexport type WindowInfoResponse = { requestId: string | null, environmentId: string | null, workspaceId: string | null, label: string, };\n\nexport type WindowNavigateEvent = { url: string, };\n\nexport type WindowSize = { width: number, height: number, };\n\nexport type WorkspaceAction = { label: string, icon?: Icon, };\n\nexport type WorkspaceInfo = { id: string, name: string, };\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/bindings/gen_models.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;\n\nexport type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };\n\nexport type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], };\n\nexport type CookieDomain = { \"HostOnly\": string } | { \"Suffix\": string } | \"NotPresent\" | \"Empty\";\n\nexport type CookieExpires = { \"AtUtc\": string } | \"SessionEnd\";\n\nexport type CookieJar = { model: \"cookie_jar\", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };\n\nexport type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };\n\nexport type EditorKeymap = \"default\" | \"vim\" | \"vscode\" | \"emacs\";\n\nexport type EncryptedKey = { encryptedKey: string, };\n\nexport type Environment = { model: \"environment\", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, \n/**\n * Variables defined in this environment scope.\n * Child environments override parent variables by name.\n */\nvariables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };\n\nexport type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };\n\nexport type Folder = { model: \"folder\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };\n\nexport type GraphQlIntrospection = { model: \"graphql_introspection\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };\n\nexport type GrpcConnection = { model: \"grpc_connection\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };\n\nexport type GrpcConnectionState = \"initialized\" | \"connected\" | \"closed\";\n\nexport type GrpcEvent = { model: \"grpc_event\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };\n\nexport type GrpcEventType = \"info\" | \"error\" | \"client_message\" | \"server_message\" | \"connection_start\" | \"connection_end\";\n\nexport type GrpcRequest = { model: \"grpc_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, \n/**\n * Server URL (http for plaintext or https for secure)\n */\nurl: string, };\n\nexport type HttpRequest = { model: \"http_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, \n/**\n * URL parameters used for both path placeholders (`:id`) and query string entries.\n */\nurlParameters: Array<HttpUrlParameter>, };\n\nexport type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };\n\nexport type HttpResponse = { model: \"http_response\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };\n\nexport type HttpResponseEvent = { model: \"http_response_event\", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };\n\n/**\n * Serializable representation of HTTP response events for DB storage.\n * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.\n * The `From` impl is in yaak-http to avoid circular dependencies.\n */\nexport type HttpResponseEventData = { \"type\": \"setting\", name: string, value: string, } | { \"type\": \"info\", message: string, } | { \"type\": \"redirect\", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { \"type\": \"send_url\", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { \"type\": \"receive_url\", version: string, status: string, } | { \"type\": \"header_up\", name: string, value: string, } | { \"type\": \"header_down\", name: string, value: string, } | { \"type\": \"chunk_sent\", bytes: number, } | { \"type\": \"chunk_received\", bytes: number, } | { \"type\": \"dns_resolved\", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };\n\nexport type HttpResponseHeader = { name: string, value: string, };\n\nexport type HttpResponseState = \"initialized\" | \"connected\" | \"closed\";\n\nexport type HttpUrlParameter = { enabled?: boolean, \n/**\n * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`\n * Other entries are appended as query parameters\n */\nname: string, value: string, id?: string, };\n\nexport type KeyValue = { model: \"key_value\", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };\n\nexport type Plugin = { model: \"plugin\", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };\n\nexport type PluginSource = \"bundled\" | \"filesystem\" | \"registry\";\n\nexport type ProxySetting = { \"type\": \"enabled\", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { \"type\": \"disabled\" };\n\nexport type ProxySettingAuth = { user: string, password: string, };\n\nexport type Settings = { model: \"settings\", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array<string> }, };\n\nexport type SyncState = { model: \"sync_state\", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };\n\nexport type WebsocketConnection = { model: \"websocket_connection\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };\n\nexport type WebsocketConnectionState = \"initialized\" | \"connected\" | \"closing\" | \"closed\";\n\nexport type WebsocketEvent = { model: \"websocket_event\", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };\n\nexport type WebsocketEventType = \"binary\" | \"close\" | \"frame\" | \"open\" | \"ping\" | \"pong\" | \"text\";\n\nexport type WebsocketRequest = { model: \"websocket_request\", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, \n/**\n * URL parameters used for both path placeholders (`:id`) and query string entries.\n */\nurlParameters: Array<HttpUrlParameter>, };\n\nexport type Workspace = { model: \"workspace\", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };\n\nexport type WorkspaceMeta = { model: \"workspace_meta\", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/bindings/gen_search.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type PluginMetadata = { version: string, name: string, displayName: string, description: string | null, homepageUrl: string | null, repositoryUrl: string | null, };\n\nexport type PluginVersion = { id: string, version: string, url: string, description: string | null, name: string, displayName: string, homepageUrl: string | null, repositoryUrl: string | null, checksum: string, readme: string | null, yanked: boolean, };\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/bindings/serde_json/JsonValue.ts",
    "content": "// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.\n\nexport type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/helpers.ts",
    "content": "export type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;\nexport type MaybePromise<T> = Promise<T> | T;\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/index.ts",
    "content": "export type * from \"./plugins\";\nexport type * from \"./themes\";\n\nexport * from \"./bindings/gen_models\";\nexport * from \"./bindings/gen_events\";\n\n// Some extras for utility\n\nexport type { PartialImportResources } from \"./plugins/ImporterPlugin\";\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/plugins/AuthenticationPlugin.ts",
    "content": "import type {\n  CallHttpAuthenticationActionArgs,\n  CallHttpAuthenticationRequest,\n  CallHttpAuthenticationResponse,\n  FormInput,\n  GetHttpAuthenticationSummaryResponse,\n  HttpAuthenticationAction,\n} from \"../bindings/gen_events\";\nimport type { MaybePromise } from \"../helpers\";\nimport type { Context } from \"./Context\";\n\ntype AddDynamicMethod<T> = {\n  dynamic?: (\n    ctx: Context,\n    args: CallHttpAuthenticationActionArgs,\n  ) => MaybePromise<Partial<T> | null | undefined>;\n};\n\n// oxlint-disable-next-line no-explicit-any -- distributive conditional type pattern\ntype AddDynamic<T> = T extends any\n  ? T extends { inputs?: FormInput[] }\n    ? Omit<T, \"inputs\"> & {\n        inputs: Array<AddDynamic<FormInput>>;\n        dynamic?: (\n          ctx: Context,\n          args: CallHttpAuthenticationActionArgs,\n        ) => MaybePromise<\n          Partial<Omit<T, \"inputs\"> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined\n        >;\n      }\n    : T & AddDynamicMethod<T>\n  : never;\n\nexport type DynamicAuthenticationArg = AddDynamic<FormInput>;\n\nexport type AuthenticationPlugin = GetHttpAuthenticationSummaryResponse & {\n  args: DynamicAuthenticationArg[];\n  onApply(\n    ctx: Context,\n    args: CallHttpAuthenticationRequest,\n  ): MaybePromise<CallHttpAuthenticationResponse>;\n  actions?: (HttpAuthenticationAction & {\n    onSelect(ctx: Context, args: CallHttpAuthenticationActionArgs): Promise<void> | void;\n  })[];\n};\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/plugins/Context.ts",
    "content": "import type {\n  FindHttpResponsesRequest,\n  FindHttpResponsesResponse,\n  FormInput,\n  GetCookieValueRequest,\n  GetCookieValueResponse,\n  GetHttpRequestByIdRequest,\n  GetHttpRequestByIdResponse,\n  JsonPrimitive,\n  ListCookieNamesResponse,\n  ListFoldersRequest,\n  ListFoldersResponse,\n  ListHttpRequestsRequest,\n  ListHttpRequestsResponse,\n  OpenWindowRequest,\n  PromptFormRequest,\n  PromptFormResponse,\n  PromptTextRequest,\n  PromptTextResponse,\n  RenderGrpcRequestRequest,\n  RenderGrpcRequestResponse,\n  RenderHttpRequestRequest,\n  RenderHttpRequestResponse,\n  SendHttpRequestRequest,\n  SendHttpRequestResponse,\n  ShowToastRequest,\n  TemplateRenderRequest,\n  WorkspaceInfo,\n} from \"../bindings/gen_events.ts\";\nimport type { Folder, HttpRequest } from \"../bindings/gen_models.ts\";\nimport type { JsonValue } from \"../bindings/serde_json/JsonValue\";\nimport type { MaybePromise } from \"../helpers\";\n\nexport type CallPromptFormDynamicArgs = {\n  values: { [key in string]?: JsonPrimitive };\n};\n\ntype AddDynamicMethod<T> = {\n  dynamic?: (\n    ctx: Context,\n    args: CallPromptFormDynamicArgs,\n  ) => MaybePromise<Partial<T> | null | undefined>;\n};\n\n// oxlint-disable-next-line no-explicit-any -- distributive conditional type pattern\ntype AddDynamic<T> = T extends any\n  ? T extends { inputs?: FormInput[] }\n    ? Omit<T, \"inputs\"> & {\n        inputs: Array<AddDynamic<FormInput>>;\n        dynamic?: (\n          ctx: Context,\n          args: CallPromptFormDynamicArgs,\n        ) => MaybePromise<\n          Partial<Omit<T, \"inputs\"> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined\n        >;\n      }\n    : T & AddDynamicMethod<T>\n  : never;\n\nexport type DynamicPromptFormArg = AddDynamic<FormInput>;\n\ntype DynamicPromptFormRequest = Omit<PromptFormRequest, \"inputs\"> & {\n  inputs: DynamicPromptFormArg[];\n};\n\nexport type WorkspaceHandle = Pick<WorkspaceInfo, \"id\" | \"name\">;\n\nexport interface Context {\n  clipboard: {\n    copyText(text: string): Promise<void>;\n  };\n  toast: {\n    show(args: ShowToastRequest): Promise<void>;\n  };\n  prompt: {\n    text(args: PromptTextRequest): Promise<PromptTextResponse[\"value\"]>;\n    form(args: DynamicPromptFormRequest): Promise<PromptFormResponse[\"values\"]>;\n  };\n  store: {\n    set<T>(key: string, value: T): Promise<void>;\n    get<T>(key: string): Promise<T | undefined>;\n    delete(key: string): Promise<boolean>;\n  };\n  window: {\n    requestId(): Promise<string | null>;\n    workspaceId(): Promise<string | null>;\n    environmentId(): Promise<string | null>;\n    openUrl(\n      args: OpenWindowRequest & {\n        onNavigate?: (args: { url: string }) => void;\n        onClose?: () => void;\n      },\n    ): Promise<{ close: () => void }>;\n    openExternalUrl(url: string): Promise<void>;\n  };\n  cookies: {\n    listNames(): Promise<ListCookieNamesResponse[\"names\"]>;\n    getValue(args: GetCookieValueRequest): Promise<GetCookieValueResponse[\"value\"]>;\n  };\n  grpcRequest: {\n    render(args: RenderGrpcRequestRequest): Promise<RenderGrpcRequestResponse[\"grpcRequest\"]>;\n  };\n  httpRequest: {\n    send(args: SendHttpRequestRequest): Promise<SendHttpRequestResponse[\"httpResponse\"]>;\n    getById(args: GetHttpRequestByIdRequest): Promise<GetHttpRequestByIdResponse[\"httpRequest\"]>;\n    render(args: RenderHttpRequestRequest): Promise<RenderHttpRequestResponse[\"httpRequest\"]>;\n    list(args?: ListHttpRequestsRequest): Promise<ListHttpRequestsResponse[\"httpRequests\"]>;\n    create(\n      args: Omit<Partial<HttpRequest>, \"id\" | \"model\" | \"createdAt\" | \"updatedAt\"> &\n        Pick<HttpRequest, \"workspaceId\" | \"url\">,\n    ): Promise<HttpRequest>;\n    update(\n      args: Omit<Partial<HttpRequest>, \"model\" | \"createdAt\" | \"updatedAt\"> &\n        Pick<HttpRequest, \"id\">,\n    ): Promise<HttpRequest>;\n    delete(args: { id: string }): Promise<HttpRequest>;\n  };\n  folder: {\n    list(args?: ListFoldersRequest): Promise<ListFoldersResponse[\"folders\"]>;\n    getById(args: { id: string }): Promise<Folder | null>;\n    create(\n      args: Omit<Partial<Folder>, \"id\" | \"model\" | \"createdAt\" | \"updatedAt\"> &\n        Pick<Folder, \"workspaceId\" | \"name\">,\n    ): Promise<Folder>;\n    update(\n      args: Omit<Partial<Folder>, \"model\" | \"createdAt\" | \"updatedAt\"> & Pick<Folder, \"id\">,\n    ): Promise<Folder>;\n    delete(args: { id: string }): Promise<Folder>;\n  };\n  httpResponse: {\n    find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse[\"httpResponses\"]>;\n  };\n  templates: {\n    render<T extends JsonValue>(args: TemplateRenderRequest & { data: T }): Promise<T>;\n  };\n  plugin: {\n    reload(): void;\n  };\n  workspace: {\n    list(): Promise<WorkspaceHandle[]>;\n    withContext(handle: WorkspaceHandle): Context;\n  };\n}\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/plugins/FilterPlugin.ts",
    "content": "import type { FilterResponse } from \"../bindings/gen_events\";\nimport type { Context } from \"./Context\";\n\nexport type FilterPlugin = {\n  name: string;\n  description?: string;\n  onFilter(\n    ctx: Context,\n    args: { payload: string; filter: string; mimeType: string },\n  ): Promise<FilterResponse> | FilterResponse;\n};\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/plugins/FolderActionPlugin.ts",
    "content": "import type { CallFolderActionArgs, FolderAction } from \"../bindings/gen_events\";\nimport type { Context } from \"./Context\";\n\nexport type FolderActionPlugin = FolderAction & {\n  onSelect(ctx: Context, args: CallFolderActionArgs): Promise<void> | void;\n};\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/plugins/GrpcRequestActionPlugin.ts",
    "content": "import type { CallGrpcRequestActionArgs, GrpcRequestAction } from \"../bindings/gen_events\";\nimport type { Context } from \"./Context\";\n\nexport type GrpcRequestActionPlugin = GrpcRequestAction & {\n  onSelect(ctx: Context, args: CallGrpcRequestActionArgs): Promise<void> | void;\n};\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/plugins/HttpRequestActionPlugin.ts",
    "content": "import type { CallHttpRequestActionArgs, HttpRequestAction } from \"../bindings/gen_events\";\nimport type { Context } from \"./Context\";\n\nexport type HttpRequestActionPlugin = HttpRequestAction & {\n  onSelect(ctx: Context, args: CallHttpRequestActionArgs): Promise<void> | void;\n};\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/plugins/ImporterPlugin.ts",
    "content": "import type { ImportResources } from \"../bindings/gen_events\";\nimport type { AtLeast, MaybePromise } from \"../helpers\";\nimport type { Context } from \"./Context\";\n\ntype RootFields = \"name\" | \"id\" | \"model\";\ntype CommonFields = RootFields | \"workspaceId\";\n\nexport type PartialImportResources = {\n  workspaces: Array<AtLeast<ImportResources[\"workspaces\"][0], RootFields>>;\n  environments: Array<AtLeast<ImportResources[\"environments\"][0], CommonFields>>;\n  folders: Array<AtLeast<ImportResources[\"folders\"][0], CommonFields>>;\n  httpRequests: Array<AtLeast<ImportResources[\"httpRequests\"][0], CommonFields>>;\n  grpcRequests: Array<AtLeast<ImportResources[\"grpcRequests\"][0], CommonFields>>;\n  websocketRequests: Array<AtLeast<ImportResources[\"websocketRequests\"][0], CommonFields>>;\n};\n\nexport type ImportPluginResponse = null | {\n  resources: PartialImportResources;\n};\n\nexport type ImporterPlugin = {\n  name: string;\n  description?: string;\n  onImport(\n    ctx: Context,\n    args: { text: string },\n  ): MaybePromise<ImportPluginResponse | null | undefined>;\n};\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/plugins/TemplateFunctionPlugin.ts",
    "content": "import type { CallTemplateFunctionArgs, FormInput, TemplateFunction } from \"../bindings/gen_events\";\nimport type { MaybePromise } from \"../helpers\";\nimport type { Context } from \"./Context\";\n\ntype AddDynamicMethod<T> = {\n  dynamic?: (\n    ctx: Context,\n    args: CallTemplateFunctionArgs,\n  ) => MaybePromise<Partial<T> | null | undefined>;\n};\n\n// oxlint-disable-next-line no-explicit-any -- distributive conditional type pattern\ntype AddDynamic<T> = T extends any\n  ? T extends { inputs?: FormInput[] }\n    ? Omit<T, \"inputs\"> & {\n        inputs: Array<AddDynamic<FormInput>>;\n        dynamic?: (\n          ctx: Context,\n          args: CallTemplateFunctionArgs,\n        ) => MaybePromise<\n          Partial<Omit<T, \"inputs\"> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined\n        >;\n      }\n    : T & AddDynamicMethod<T>\n  : never;\n\nexport type DynamicTemplateFunctionArg = AddDynamic<FormInput>;\n\nexport type TemplateFunctionPlugin = Omit<TemplateFunction, \"args\"> & {\n  args: DynamicTemplateFunctionArg[];\n  onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null>;\n};\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/plugins/ThemePlugin.ts",
    "content": "import type { Theme } from \"../bindings/gen_events\";\n\nexport type ThemePlugin = Theme;\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/plugins/WebsocketRequestActionPlugin.ts",
    "content": "import type {\n  CallWebsocketRequestActionArgs,\n  WebsocketRequestAction,\n} from \"../bindings/gen_events\";\nimport type { Context } from \"./Context\";\n\nexport type WebsocketRequestActionPlugin = WebsocketRequestAction & {\n  onSelect(ctx: Context, args: CallWebsocketRequestActionArgs): Promise<void> | void;\n};\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/plugins/WorkspaceActionPlugin.ts",
    "content": "import type { CallWorkspaceActionArgs, WorkspaceAction } from \"../bindings/gen_events\";\nimport type { Context } from \"./Context\";\n\nexport type WorkspaceActionPlugin = WorkspaceAction & {\n  onSelect(ctx: Context, args: CallWorkspaceActionArgs): Promise<void> | void;\n};\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/plugins/index.ts",
    "content": "import type { AuthenticationPlugin } from \"./AuthenticationPlugin\";\n\nimport type { Context } from \"./Context\";\nimport type { FilterPlugin } from \"./FilterPlugin\";\nimport type { FolderActionPlugin } from \"./FolderActionPlugin\";\nimport type { GrpcRequestActionPlugin } from \"./GrpcRequestActionPlugin\";\nimport type { HttpRequestActionPlugin } from \"./HttpRequestActionPlugin\";\nimport type { ImporterPlugin } from \"./ImporterPlugin\";\nimport type { TemplateFunctionPlugin } from \"./TemplateFunctionPlugin\";\nimport type { ThemePlugin } from \"./ThemePlugin\";\nimport type { WebsocketRequestActionPlugin } from \"./WebsocketRequestActionPlugin\";\nimport type { WorkspaceActionPlugin } from \"./WorkspaceActionPlugin\";\n\nexport type { Context };\nexport type { DynamicAuthenticationArg } from \"./AuthenticationPlugin\";\nexport type { CallPromptFormDynamicArgs, DynamicPromptFormArg } from \"./Context\";\nexport type { DynamicTemplateFunctionArg } from \"./TemplateFunctionPlugin\";\nexport type { TemplateFunctionPlugin };\nexport type { FolderActionPlugin } from \"./FolderActionPlugin\";\nexport type { WorkspaceActionPlugin } from \"./WorkspaceActionPlugin\";\n\n/**\n * The global structure of a Yaak plugin\n */\nexport type PluginDefinition = {\n  init?: (ctx: Context) => void | Promise<void>;\n  dispose?: () => void | Promise<void>;\n  importer?: ImporterPlugin;\n  themes?: ThemePlugin[];\n  filter?: FilterPlugin;\n  authentication?: AuthenticationPlugin;\n  httpRequestActions?: HttpRequestActionPlugin[];\n  websocketRequestActions?: WebsocketRequestActionPlugin[];\n  workspaceActions?: WorkspaceActionPlugin[];\n  folderActions?: FolderActionPlugin[];\n  grpcRequestActions?: GrpcRequestActionPlugin[];\n  templateFunctions?: TemplateFunctionPlugin[];\n};\n"
  },
  {
    "path": "packages/plugin-runtime-types/src/themes/index.ts",
    "content": "export type Colors = {\n  surface: string;\n  surfaceHighlight?: string;\n  surfaceActive?: string;\n\n  text: string;\n  textSubtle?: string;\n  textSubtlest?: string;\n\n  border?: string;\n  borderSubtle?: string;\n  borderFocus?: string;\n\n  shadow?: string;\n  backdrop?: string;\n  selection?: string;\n\n  primary?: string;\n  secondary?: string;\n  info?: string;\n  success?: string;\n  notice?: string;\n  warning?: string;\n  danger?: string;\n};\n\nexport type Index = Colors & {\n  id: string;\n  name: string;\n  components?: Partial<{\n    dialog: Partial<Colors>;\n    menu: Partial<Colors>;\n    toast: Partial<Colors>;\n    sidebar: Partial<Colors>;\n    responsePane: Partial<Colors>;\n    appHeader: Partial<Colors>;\n    button: Partial<Colors>;\n    banner: Partial<Colors>;\n    placeholder: Partial<Colors>;\n    urlBar: Partial<Colors>;\n    editor: Partial<Colors>;\n    input: Partial<Colors>;\n  }>;\n};\n"
  },
  {
    "path": "packages/plugin-runtime-types/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"module\": \"node16\",\n    \"target\": \"es6\",\n    \"lib\": [\"es2021\", \"dom\"],\n    \"declaration\": true,\n    \"declarationDir\": \"./lib\",\n    \"outDir\": \"./lib\",\n    \"strict\": true,\n    \"types\": [\"node\"]\n  },\n  \"files\": [\"src/index.ts\"]\n}\n"
  },
  {
    "path": "plugins/.gitignore",
    "content": "*/build\n"
  },
  {
    "path": "plugins/action-copy-curl/README.md",
    "content": "# Copy as cUrl\n\nA request action plugin for Yaak that converts HTTP requests into [curl](https://curl.se)\ncommands, making it easy to share, debug, and execute requests outside Yaak.\n\n![Screenshot of context menu](screenshot.png)\n\n## Overview\n\nThis plugin adds a 'Copy as Curl' action to HTTP requests, converting any request into its\nequivalent curl command. This is useful for debugging, sharing requests with team members,\nand executing requests in terminal environments where `curl` is available.\n\n## How It Works\n\nThe plugin analyzes the given HTTP request and generates a properly formatted curl command\nthat includes:\n\n- HTTP method (GET, POST, PUT, DELETE, etc.)\n- Request URL with query parameters\n- Headers (including authentication headers)\n- Request body (for POST, PUT, PATCH requests)\n- Authentication credentials\n\n## Usage\n\n1. Configure an HTTP request as usual in Yaak\n2. Right-click on the request in the sidebar\n3. Select 'Copy as Curl'\n4. The command is copied to your clipboard\n5. Share or execute the command\n\n## Generated Curl Examples\n\n### Simple GET Request\n\n```bash\ncurl -X GET 'https://api.example.com/users' \\\n  --header 'Accept: application/json'\n```\n\n### POST Request with JSON Data\n\n```bash\ncurl -X POST 'https://api.example.com/users' \\\n  --header 'Content-Type: application/json' \\\n  --header 'Accept: application/json' \\\n  --data '{\n    \"name\": \"John Doe\",\n    \"email\": \"john@example.com\"\n  }'\n```\n\n### Request with Multi-part Form Data\n\n```bash\ncurl -X POST 'yaak.app' \\\n  --header 'Content-Type: multipart/form-data' \\\n  --form 'hello=world' \\\n  --form file=@/path/to/file.json\n```\n\n### Request with Authentication\n\n```bash\ncurl -X GET 'https://api.example.com/protected' \\\n  --user 'username:password'\n```\n"
  },
  {
    "path": "plugins/action-copy-curl/package.json",
    "content": "{\n  \"name\": \"@yaak/action-copy-curl\",\n  \"displayName\": \"Copy as Curl\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Copy request as a curl command\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins/action-copy-curl\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  }\n}\n"
  },
  {
    "path": "plugins/action-copy-curl/src/index.ts",
    "content": "import type { HttpRequest, PluginDefinition } from \"@yaakapp/api\";\n\nconst NEWLINE = \"\\\\\\n \";\n\nexport const plugin: PluginDefinition = {\n  httpRequestActions: [\n    {\n      label: \"Copy as Curl\",\n      icon: \"copy\",\n      async onSelect(ctx, args) {\n        const rendered_request = await ctx.httpRequest.render({\n          httpRequest: args.httpRequest,\n          purpose: \"send\",\n        });\n        const data = await convertToCurl(rendered_request);\n        await ctx.clipboard.copyText(data);\n        await ctx.toast.show({\n          message: \"Command copied to clipboard\",\n          icon: \"copy\",\n          color: \"success\",\n        });\n      },\n    },\n  ],\n};\n\nexport async function convertToCurl(request: Partial<HttpRequest>) {\n  const xs = [\"curl\"];\n\n  // Add method and URL all on first line\n  if (request.method) xs.push(\"-X\", request.method);\n\n  // Build final URL with parameters (compatible with old curl)\n  let finalUrl = request.url || \"\";\n  const urlParams = (request.urlParameters ?? []).filter(onlyEnabled);\n  if (urlParams.length > 0) {\n    // Build url\n    const [base, hash] = finalUrl.split(\"#\");\n    const separator = base?.includes(\"?\") ? \"&\" : \"?\";\n    const queryString = urlParams\n      .map((p) => `${encodeURIComponent(p.name)}=${encodeURIComponent(p.value)}`)\n      .join(\"&\");\n    finalUrl = base + separator + queryString + (hash ? `#${hash}` : \"\");\n  }\n\n  // Add API key authentication\n  if (request.authenticationType === \"apikey\") {\n    if (request.authentication?.location === \"query\") {\n      const sep = finalUrl.includes(\"?\") ? \"&\" : \"?\";\n      finalUrl = [\n        finalUrl,\n        sep,\n        encodeURIComponent(request.authentication?.key ?? \"token\"),\n        \"=\",\n        encodeURIComponent(request.authentication?.value ?? \"\"),\n      ].join(\"\");\n    } else {\n      request.headers = request.headers ?? [];\n      request.headers.push({\n        name: request.authentication?.key ?? \"X-Api-Key\",\n        value: request.authentication?.value ?? \"\",\n      });\n    }\n  }\n\n  xs.push(quote(finalUrl));\n  xs.push(NEWLINE);\n\n  // Add headers\n  for (const h of (request.headers ?? []).filter(onlyEnabled)) {\n    xs.push(\"--header\", quote(`${h.name}: ${h.value}`));\n    xs.push(NEWLINE);\n  }\n\n  // Add form params\n  const type = request.bodyType ?? \"none\";\n  if (\n    (type === \"multipart/form-data\" || type === \"application/x-www-form-urlencoded\") &&\n    Array.isArray(request.body?.form)\n  ) {\n    const flag = request.bodyType === \"multipart/form-data\" ? \"--form\" : \"--data\";\n    for (const p of (request.body?.form ?? []).filter(onlyEnabled)) {\n      if (p.file) {\n        let v = `${p.name}=@${p.file}`;\n        v += p.contentType ? `;type=${p.contentType}` : \"\";\n        xs.push(flag, v);\n      } else {\n        xs.push(flag, quote(`${p.name}=${p.value}`));\n      }\n      xs.push(NEWLINE);\n    }\n  } else if (type === \"graphql\" && typeof request.body?.query === \"string\") {\n    const body = {\n      query: request.body.query || \"\",\n      variables: maybeParseJSON(request.body.variables, undefined),\n    };\n    xs.push(\"--data\", quote(JSON.stringify(body)));\n    xs.push(NEWLINE);\n  } else if (type !== \"none\" && typeof request.body?.text === \"string\") {\n    xs.push(\"--data\", quote(request.body.text));\n    xs.push(NEWLINE);\n  }\n\n  // Add basic/digest authentication\n  if (request.authentication?.disabled !== true) {\n    if (request.authenticationType === \"basic\" || request.authenticationType === \"digest\") {\n      if (request.authenticationType === \"digest\") xs.push(\"--digest\");\n      xs.push(\n        \"--user\",\n        quote(\n          `${request.authentication?.username ?? \"\"}:${request.authentication?.password ?? \"\"}`,\n        ),\n      );\n      xs.push(NEWLINE);\n    }\n\n    // Add bearer authentication\n    if (request.authenticationType === \"bearer\") {\n      const value =\n        `${request.authentication?.prefix ?? \"Bearer\"} ${request.authentication?.token ?? \"\"}`.trim();\n      xs.push(\"--header\", quote(`Authorization: ${value}`));\n      xs.push(NEWLINE);\n    }\n\n    if (request.authenticationType === \"auth-aws-sig-v4\") {\n      xs.push(\n        \"--aws-sigv4\",\n        [\n          \"aws\",\n          \"amz\",\n          request.authentication?.region ?? \"\",\n          request.authentication?.service ?? \"\",\n        ].join(\":\"),\n      );\n      xs.push(NEWLINE);\n      xs.push(\n        \"--user\",\n        quote(\n          `${request.authentication?.accessKeyId ?? \"\"}:${request.authentication?.secretAccessKey ?? \"\"}`,\n        ),\n      );\n      if (request.authentication?.sessionToken) {\n        xs.push(NEWLINE);\n        xs.push(\"--header\", quote(`X-Amz-Security-Token: ${request.authentication.sessionToken}`));\n      }\n      xs.push(NEWLINE);\n    }\n  }\n\n  // Remove trailing newline\n  if (xs[xs.length - 1] === NEWLINE) {\n    xs.splice(xs.length - 1, 1);\n  }\n\n  return xs.join(\" \");\n}\n\nfunction quote(arg: string): string {\n  const escaped = arg.replace(/'/g, \"\\\\'\");\n  return `'${escaped}'`;\n}\n\nfunction onlyEnabled(v: { name?: string; enabled?: boolean }): boolean {\n  return v.enabled !== false && !!v.name;\n}\n\nfunction maybeParseJSON<T>(v: string, fallback: T) {\n  try {\n    return JSON.parse(v);\n  } catch {\n    return fallback;\n  }\n}\n"
  },
  {
    "path": "plugins/action-copy-curl/tests/index.test.ts",
    "content": "import { describe, expect, test } from \"vite-plus/test\";\nimport { convertToCurl } from \"../src\";\n\ndescribe(\"exporter-curl\", () => {\n  test(\"Exports GET with params\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        urlParameters: [\n          { name: \"a\", value: \"aaa\" },\n          { name: \"b\", value: \"bbb\", enabled: true },\n          { name: \"c\", value: \"ccc\", enabled: false },\n        ],\n      }),\n    ).toEqual([`curl 'https://yaak.app?a=aaa&b=bbb'`].join(\" \\\\n  \"));\n  });\n\n  test(\"Exports GET with params and hash\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app/path#section\",\n        urlParameters: [\n          { name: \"a\", value: \"aaa\" },\n          { name: \"b\", value: \"bbb\", enabled: true },\n          { name: \"c\", value: \"ccc\", enabled: false },\n        ],\n      }),\n    ).toEqual([`curl 'https://yaak.app/path?a=aaa&b=bbb#section'`].join(\" \\\\n  \"));\n  });\n\n  test(\"Exports POST with url form data\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        bodyType: \"application/x-www-form-urlencoded\",\n        body: {\n          form: [\n            { name: \"a\", value: \"aaa\" },\n            { name: \"b\", value: \"bbb\", enabled: true },\n            { name: \"c\", value: \"ccc\", enabled: false },\n          ],\n        },\n      }),\n    ).toEqual(\n      [`curl -X POST 'https://yaak.app'`, `--data 'a=aaa'`, `--data 'b=bbb'`].join(\" \\\\\\n  \"),\n    );\n  });\n\n  test(\"Exports POST with GraphQL data\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        bodyType: \"graphql\",\n        body: {\n          query: \"{foo,bar}\",\n          variables: '{\"a\": \"aaa\", \"b\": \"bbb\"}',\n        },\n      }),\n    ).toEqual(\n      [\n        `curl -X POST 'https://yaak.app'`,\n        `--data '{\"query\":\"{foo,bar}\",\"variables\":{\"a\":\"aaa\",\"b\":\"bbb\"}}'`,\n      ].join(\" \\\\\\n  \"),\n    );\n  });\n\n  test(\"Exports POST with GraphQL data no variables\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        bodyType: \"graphql\",\n        body: {\n          query: \"{foo,bar}\",\n        },\n      }),\n    ).toEqual(\n      [`curl -X POST 'https://yaak.app'`, `--data '{\"query\":\"{foo,bar}\"}'`].join(\" \\\\\\n  \"),\n    );\n  });\n\n  test(\"Exports PUT with multipart form\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        method: \"PUT\",\n        bodyType: \"multipart/form-data\",\n        body: {\n          form: [\n            { name: \"a\", value: \"aaa\" },\n            { name: \"b\", value: \"bbb\", enabled: true },\n            { name: \"c\", value: \"ccc\", enabled: false },\n            { name: \"f\", file: \"/foo/bar.png\", contentType: \"image/png\" },\n          ],\n        },\n      }),\n    ).toEqual(\n      [\n        `curl -X PUT 'https://yaak.app'`,\n        `--form 'a=aaa'`,\n        `--form 'b=bbb'`,\n        \"--form f=@/foo/bar.png;type=image/png\",\n      ].join(\" \\\\\\n  \"),\n    );\n  });\n\n  test(\"Exports JSON body\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        bodyType: \"application/json\",\n        body: {\n          text: `{\"foo\":\"bar's\"}`,\n        },\n        headers: [{ name: \"Content-Type\", value: \"application/json\" }],\n      }),\n    ).toEqual(\n      [\n        `curl -X POST 'https://yaak.app'`,\n        `--header 'Content-Type: application/json'`,\n        `--data '{\"foo\":\"bar\\\\'s\"}'`,\n      ].join(\" \\\\\\n  \"),\n    );\n  });\n\n  test(\"Exports multi-line JSON body\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        bodyType: \"application/json\",\n        body: {\n          text: `{\"foo\":\"bar\",\\n\"baz\":\"qux\"}`,\n        },\n        headers: [{ name: \"Content-Type\", value: \"application/json\" }],\n      }),\n    ).toEqual(\n      [\n        `curl -X POST 'https://yaak.app'`,\n        `--header 'Content-Type: application/json'`,\n        `--data '{\"foo\":\"bar\",\\n\"baz\":\"qux\"}'`,\n      ].join(\" \\\\\\n  \"),\n    );\n  });\n\n  test(\"Exports headers\", async () => {\n    expect(\n      await convertToCurl({\n        headers: [\n          { name: \"a\", value: \"aaa\" },\n          { name: \"b\", value: \"bbb\", enabled: true },\n          { name: \"c\", value: \"ccc\", enabled: false },\n        ],\n      }),\n    ).toEqual([`curl ''`, `--header 'a: aaa'`, `--header 'b: bbb'`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"Basic auth\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        authenticationType: \"basic\",\n        authentication: {\n          username: \"user\",\n          password: \"pass\",\n        },\n      }),\n    ).toEqual([`curl 'https://yaak.app'`, `--user 'user:pass'`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"Basic auth disabled\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        authenticationType: \"basic\",\n        authentication: {\n          disabled: true,\n          username: \"user\",\n          password: \"pass\",\n        },\n      }),\n    ).toEqual([`curl 'https://yaak.app'`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"Broken basic auth\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        authenticationType: \"basic\",\n        authentication: {},\n      }),\n    ).toEqual([`curl 'https://yaak.app'`, `--user ':'`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"Digest auth\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        authenticationType: \"digest\",\n        authentication: {\n          username: \"user\",\n          password: \"pass\",\n        },\n      }),\n    ).toEqual([`curl 'https://yaak.app'`, `--digest --user 'user:pass'`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"Bearer auth\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        authenticationType: \"bearer\",\n        authentication: {\n          token: \"tok\",\n        },\n      }),\n    ).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: Bearer tok'`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"Bearer auth with custom prefix\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        authenticationType: \"bearer\",\n        authentication: {\n          token: \"abc123\",\n          prefix: \"Token\",\n        },\n      }),\n    ).toEqual(\n      [`curl 'https://yaak.app'`, `--header 'Authorization: Token abc123'`].join(\" \\\\\\n  \"),\n    );\n  });\n\n  test(\"Bearer auth with empty prefix\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        authenticationType: \"bearer\",\n        authentication: {\n          token: \"xyz789\",\n          prefix: \"\",\n        },\n      }),\n    ).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: xyz789'`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"Broken bearer auth\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        authenticationType: \"bearer\",\n        authentication: {\n          username: \"user\",\n          password: \"pass\",\n        },\n      }),\n    ).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: Bearer'`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"AWS v4 auth\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        authenticationType: \"auth-aws-sig-v4\",\n        authentication: {\n          accessKeyId: \"ak\",\n          secretAccessKey: \"sk\",\n          sessionToken: \"\",\n          region: \"us-east-1\",\n          service: \"s3\",\n        },\n      }),\n    ).toEqual(\n      [`curl 'https://yaak.app'`, \"--aws-sigv4 aws:amz:us-east-1:s3\", `--user 'ak:sk'`].join(\n        \" \\\\\\n  \",\n      ),\n    );\n  });\n\n  test(\"AWS v4 auth with session\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        authenticationType: \"auth-aws-sig-v4\",\n        authentication: {\n          accessKeyId: \"ak\",\n          secretAccessKey: \"sk\",\n          sessionToken: \"st\",\n          region: \"us-east-1\",\n          service: \"s3\",\n        },\n      }),\n    ).toEqual(\n      [\n        `curl 'https://yaak.app'`,\n        \"--aws-sigv4 aws:amz:us-east-1:s3\",\n        `--user 'ak:sk'`,\n        `--header 'X-Amz-Security-Token: st'`,\n      ].join(\" \\\\\\n  \"),\n    );\n  });\n\n  test(\"API key auth header\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        authenticationType: \"apikey\",\n        authentication: {\n          location: \"header\",\n          key: \"X-Header\",\n          value: \"my-token\",\n        },\n      }),\n    ).toEqual([`curl 'https://yaak.app'`, `--header 'X-Header: my-token'`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"API key auth header query\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app?hi=there\",\n        urlParameters: [{ name: \"param\", value: \"hi\" }],\n        authenticationType: \"apikey\",\n        authentication: {\n          location: \"query\",\n          key: \"foo\",\n          value: \"bar\",\n        },\n      }),\n    ).toEqual([`curl 'https://yaak.app?hi=there&param=hi&foo=bar'`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"API key auth header query with params\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        urlParameters: [{ name: \"param\", value: \"hi\" }],\n        authenticationType: \"apikey\",\n        authentication: {\n          location: \"query\",\n          key: \"foo\",\n          value: \"bar\",\n        },\n      }),\n    ).toEqual([`curl 'https://yaak.app?param=hi&foo=bar'`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"API key auth header default\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        authenticationType: \"apikey\",\n        authentication: {\n          location: \"header\",\n        },\n      }),\n    ).toEqual([`curl 'https://yaak.app'`, `--header 'X-Api-Key: '`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"API key auth query\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        authenticationType: \"apikey\",\n        authentication: {\n          location: \"query\",\n          key: \"foo\",\n          value: \"bar-baz\",\n        },\n      }),\n    ).toEqual([`curl 'https://yaak.app?foo=bar-baz'`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"API key auth query with existing\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app?foo=bar&baz=qux\",\n        authenticationType: \"apikey\",\n        authentication: {\n          location: \"query\",\n          key: \"hi\",\n          value: \"there\",\n        },\n      }),\n    ).toEqual([`curl 'https://yaak.app?foo=bar&baz=qux&hi=there'`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"API key auth query default\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app?foo=bar&baz=qux\",\n        authenticationType: \"apikey\",\n        authentication: {\n          location: \"query\",\n        },\n      }),\n    ).toEqual([`curl 'https://yaak.app?foo=bar&baz=qux&token='`].join(\" \\\\\\n  \"));\n  });\n\n  test(\"Stale body data\", async () => {\n    expect(\n      await convertToCurl({\n        url: \"https://yaak.app\",\n        bodyType: \"none\",\n        body: {\n          text: \"ignore me\",\n        },\n      }),\n    ).toEqual([`curl 'https://yaak.app'`].join(\" \\\\\\n  \"));\n  });\n});\n"
  },
  {
    "path": "plugins/action-copy-curl/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/action-copy-grpcurl/README.md",
    "content": "# Copy as gRPCurl\n\nAn HTTP request action plugin that converts gRPC requests\ninto [gRPCurl](https://github.com/fullstorydev/grpcurl) commands, enabling easy sharing,\ndebugging, and execution of gRPC calls outside Yaak.\n\n![Screenshot of context menu](screenshot.png)\n\n## Overview\n\nThis plugin adds a \"Copy as gRPCurl\" action to gRPC requests, converting any gRPC request\ninto its equivalent executable command. This is useful for debugging gRPC services,\nsharing requests with team members, or executing gRPC calls in terminal environments where\n`grpcurl` is available.\n\n## How It Works\n\nThe plugin analyzes your gRPC request configuration and generates a properly formatted\n`grpcurl` command that includes:\n\n- gRPC service and method names\n- Server address and port\n- Request message data (JSON format)\n- Metadata (headers)\n- Authentication credentials\n- Protocol buffer definitions\n\n## Usage\n\n1. Configure a gRPC request as usual in Yaak\n2. Right-click on the request sidebar item\n3. Select \"Copy as gRPCurl\" from the available actions\n4. The command is copied to your clipboard\n5. Share or execute the command\n\n## Generated gRPCurl Examples\n\n### Simple Unary Call\n\n```bash\ngrpcurl -plaintext \\\n  -d '{\"name\": \"John Doe\"}' \\\n  localhost:9090 \\\n  user.UserService/GetUser\n```\n\n### Call with Metadata\n\n```bash\ngrpcurl -plaintext \\\n  -H \"authorization: Bearer my-token\" \\\n  -H \"x-api-version: v1\" \\\n  -d '{\"user_id\": \"12345\"}' \\\n  api.example.com:443 \\\n  user.UserService/GetUserProfile\n```\n\n### Call with TLS\n\n```bash\ngrpcurl \\\n  -d '{\"query\": \"search term\"}' \\\n  secure-api.example.com:443 \\\n  search.SearchService/Search\n```\n\n### Call with Proto Files\n\n```bash\ngrpcurl -import-path /path/to/protos \\\n  -proto /other/path/to/user.proto \\\n  -d '{\"email\": \"user@example.com\"}' \\\n  localhost:9090 \\\n  user.UserService/CreateUser\n```\n"
  },
  {
    "path": "plugins/action-copy-grpcurl/package.json",
    "content": "{\n  \"name\": \"@yaak/action-copy-grpcurl\",\n  \"displayName\": \"Copy as gRPCurl\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Copy gRPC request as a grpcurl command\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins/action-copy-grpcurl\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  }\n}\n"
  },
  {
    "path": "plugins/action-copy-grpcurl/src/index.ts",
    "content": "import path from \"node:path\";\nimport type { GrpcRequest, PluginDefinition } from \"@yaakapp/api\";\n\nconst NEWLINE = \"\\\\\\n \";\n\nexport const plugin: PluginDefinition = {\n  grpcRequestActions: [\n    {\n      label: \"Copy as gRPCurl\",\n      icon: \"copy\",\n      async onSelect(ctx, args) {\n        const rendered_request = await ctx.grpcRequest.render({\n          grpcRequest: args.grpcRequest,\n          purpose: \"send\",\n        });\n        const data = await convert(rendered_request, args.protoFiles);\n        await ctx.clipboard.copyText(data);\n        await ctx.toast.show({\n          message: \"Command copied to clipboard\",\n          icon: \"copy\",\n          color: \"success\",\n        });\n      },\n    },\n  ],\n};\n\nexport async function convert(request: Partial<GrpcRequest>, allProtoFiles: string[]) {\n  const xs = [\"grpcurl\"];\n\n  if (request.url?.startsWith(\"http://\")) {\n    xs.push(\"-plaintext\");\n  }\n\n  const protoIncludes = allProtoFiles.filter((f) => !f.endsWith(\".proto\"));\n  const protoFiles = allProtoFiles.filter((f) => f.endsWith(\".proto\"));\n\n  const inferredIncludes = new Set<string>();\n  for (const f of protoFiles) {\n    const protoDir = findParentProtoDir(f);\n    if (protoDir) {\n      inferredIncludes.add(protoDir);\n    } else {\n      inferredIncludes.add(path.posix.join(f, \"..\"));\n      inferredIncludes.add(path.posix.join(f, \"..\", \"..\"));\n    }\n  }\n\n  for (const f of protoIncludes) {\n    xs.push(\"-import-path\", quote(f));\n    xs.push(NEWLINE);\n  }\n\n  for (const f of inferredIncludes.values()) {\n    xs.push(\"-import-path\", quote(f));\n    xs.push(NEWLINE);\n  }\n\n  for (const f of protoFiles) {\n    xs.push(\"-proto\", quote(f));\n    xs.push(NEWLINE);\n  }\n\n  // Add headers\n  for (const h of (request.metadata ?? []).filter(onlyEnabled)) {\n    xs.push(\"-H\", quote(`${h.name}: ${h.value}`));\n    xs.push(NEWLINE);\n  }\n\n  // Add basic authentication\n  if (request.authentication?.disabled !== true) {\n    if (request.authenticationType === \"basic\") {\n      const user = request.authentication?.username ?? \"\";\n      const pass = request.authentication?.password ?? \"\";\n      const encoded = btoa(`${user}:${pass}`);\n      xs.push(\"-H\", quote(`Authorization: Basic ${encoded}`));\n      xs.push(NEWLINE);\n    } else if (request.authenticationType === \"bearer\") {\n      // Add bearer authentication\n      xs.push(\"-H\", quote(`Authorization: Bearer ${request.authentication?.token ?? \"\"}`));\n      xs.push(NEWLINE);\n    } else if (request.authenticationType === \"apikey\") {\n      if (request.authentication?.location === \"query\") {\n        const sep = request.url?.includes(\"?\") ? \"&\" : \"?\";\n        request.url = [\n          request.url,\n          sep,\n          encodeURIComponent(request.authentication?.key ?? \"token\"),\n          \"=\",\n          encodeURIComponent(request.authentication?.value ?? \"\"),\n        ].join(\"\");\n      } else {\n        xs.push(\n          \"-H\",\n          quote(\n            `${request.authentication?.key ?? \"X-Api-Key\"}: ${request.authentication?.value ?? \"\"}`,\n          ),\n        );\n      }\n      xs.push(NEWLINE);\n    }\n  }\n\n  // Add form params\n  if (request.message) {\n    xs.push(\"-d\", quote(request.message));\n    xs.push(NEWLINE);\n  }\n\n  // Add the server address\n  if (request.url) {\n    const server = request.url.replace(/^https?:\\/\\//, \"\"); // remove protocol\n    xs.push(server);\n    xs.push(NEWLINE);\n  }\n\n  // Add service + method\n  if (request.service && request.method) {\n    xs.push(`${request.service}/${request.method}`);\n    xs.push(NEWLINE);\n  }\n\n  // Remove trailing newline\n  if (xs[xs.length - 1] === NEWLINE) {\n    xs.splice(xs.length - 1, 1);\n  }\n\n  return xs.join(\" \");\n}\n\nfunction quote(arg: string): string {\n  const escaped = arg.replace(/'/g, \"\\\\'\");\n  return `'${escaped}'`;\n}\n\nfunction onlyEnabled(v: { name?: string; enabled?: boolean }): boolean {\n  return v.enabled !== false && !!v.name;\n}\n\nfunction findParentProtoDir(startPath: string): string | null {\n  let dir = path.resolve(startPath);\n\n  while (true) {\n    if (path.basename(dir) === \"proto\") {\n      return dir;\n    }\n\n    const parent = path.dirname(dir);\n    if (parent === dir) {\n      return null; // Reached root\n    }\n\n    dir = parent;\n  }\n}\n"
  },
  {
    "path": "plugins/action-copy-grpcurl/tests/index.test.ts",
    "content": "import { describe, expect, test } from \"vite-plus/test\";\nimport { convert } from \"../src\";\n\ndescribe(\"exporter-curl\", () => {\n  test(\"Simple example\", async () => {\n    expect(\n      await convert(\n        {\n          url: \"https://yaak.app\",\n        },\n        [],\n      ),\n    ).toEqual([\"grpcurl yaak.app\"].join(\" \\\\\\n  \"));\n  });\n  test(\"Basic metadata\", async () => {\n    expect(\n      await convert(\n        {\n          url: \"https://yaak.app\",\n          metadata: [\n            { name: \"aaa\", value: \"AAA\" },\n            { enabled: true, name: \"bbb\", value: \"BBB\" },\n            { enabled: false, name: \"disabled\", value: \"ddd\" },\n          ],\n        },\n        [],\n      ),\n    ).toEqual([`grpcurl -H 'aaa: AAA'`, `-H 'bbb: BBB'`, \"yaak.app\"].join(\" \\\\\\n  \"));\n  });\n  test(\"Basic auth\", async () => {\n    expect(\n      await convert(\n        {\n          url: \"https://yaak.app\",\n          authenticationType: \"basic\",\n          authentication: {\n            username: \"user\",\n            password: \"pass\",\n          },\n        },\n        [],\n      ),\n    ).toEqual([`grpcurl -H 'Authorization: Basic dXNlcjpwYXNz'`, \"yaak.app\"].join(\" \\\\\\n  \"));\n  });\n\n  test(\"API key auth\", async () => {\n    expect(\n      await convert(\n        {\n          url: \"https://yaak.app\",\n          authenticationType: \"apikey\",\n          authentication: {\n            key: \"X-Token\",\n            value: \"tok\",\n          },\n        },\n        [],\n      ),\n    ).toEqual([`grpcurl -H 'X-Token: tok'`, \"yaak.app\"].join(\" \\\\\\n  \"));\n  });\n\n  test(\"API key auth\", async () => {\n    expect(\n      await convert(\n        {\n          url: \"https://yaak.app\",\n          authenticationType: \"apikey\",\n          authentication: {\n            location: \"query\",\n            key: \"token\",\n            value: \"tok 1\",\n          },\n        },\n        [],\n      ),\n    ).toEqual([\"grpcurl\", \"yaak.app?token=tok%201\"].join(\" \\\\\\n  \"));\n  });\n\n  test(\"Single proto file\", async () => {\n    expect(await convert({ url: \"https://yaak.app\" }, [\"/foo/bar/baz.proto\"])).toEqual(\n      [\n        `grpcurl -import-path '/foo/bar'`,\n        `-import-path '/foo'`,\n        `-proto '/foo/bar/baz.proto'`,\n        \"yaak.app\",\n      ].join(\" \\\\\\n  \"),\n    );\n  });\n  test(\"Multiple proto files, same dir\", async () => {\n    expect(\n      await convert({ url: \"https://yaak.app\" }, [\"/foo/bar/aaa.proto\", \"/foo/bar/bbb.proto\"]),\n    ).toEqual(\n      [\n        `grpcurl -import-path '/foo/bar'`,\n        `-import-path '/foo'`,\n        `-proto '/foo/bar/aaa.proto'`,\n        `-proto '/foo/bar/bbb.proto'`,\n        \"yaak.app\",\n      ].join(\" \\\\\\n  \"),\n    );\n  });\n  test(\"Multiple proto files, different dir\", async () => {\n    expect(\n      await convert({ url: \"https://yaak.app\" }, [\"/aaa/bbb/ccc.proto\", \"/xxx/yyy/zzz.proto\"]),\n    ).toEqual(\n      [\n        `grpcurl -import-path '/aaa/bbb'`,\n        `-import-path '/aaa'`,\n        `-import-path '/xxx/yyy'`,\n        `-import-path '/xxx'`,\n        `-proto '/aaa/bbb/ccc.proto'`,\n        `-proto '/xxx/yyy/zzz.proto'`,\n        \"yaak.app\",\n      ].join(\" \\\\\\n  \"),\n    );\n  });\n  test(\"Single include dir\", async () => {\n    expect(await convert({ url: \"https://yaak.app\" }, [\"/aaa/bbb\"])).toEqual(\n      [`grpcurl -import-path '/aaa/bbb'`, \"yaak.app\"].join(\" \\\\\\n  \"),\n    );\n  });\n  test(\"Multiple include dir\", async () => {\n    expect(await convert({ url: \"https://yaak.app\" }, [\"/aaa/bbb\", \"/xxx/yyy\"])).toEqual(\n      [`grpcurl -import-path '/aaa/bbb'`, `-import-path '/xxx/yyy'`, \"yaak.app\"].join(\" \\\\\\n  \"),\n    );\n  });\n  test(\"Mixed proto and dirs\", async () => {\n    expect(\n      await convert({ url: \"https://yaak.app\" }, [\"/aaa/bbb\", \"/xxx/yyy\", \"/foo/bar.proto\"]),\n    ).toEqual(\n      [\n        `grpcurl -import-path '/aaa/bbb'`,\n        `-import-path '/xxx/yyy'`,\n        `-import-path '/foo'`,\n        `-import-path '/'`,\n        `-proto '/foo/bar.proto'`,\n        \"yaak.app\",\n      ].join(\" \\\\\\n  \"),\n    );\n  });\n  test(\"Sends data\", async () => {\n    expect(\n      await convert(\n        {\n          url: \"https://yaak.app\",\n          message: JSON.stringify({ foo: \"bar\", baz: 1.0 }, null, 2),\n        },\n        [\"/foo.proto\"],\n      ),\n    ).toEqual(\n      [\n        `grpcurl -import-path '/'`,\n        `-proto '/foo.proto'`,\n        `-d '{\\n  \"foo\": \"bar\",\\n  \"baz\": 1\\n}'`,\n        \"yaak.app\",\n      ].join(\" \\\\\\n  \"),\n    );\n  });\n\n  test(\"Sends data with unresolved template tags\", async () => {\n    expect(\n      await convert(\n        {\n          url: \"https://yaak.app\",\n          message: '{\"timestamp\": ${[ faker \"timestamp\" ]}, \"foo\": \"bar\"}',\n        },\n        [\"/foo.proto\"],\n      ),\n    ).toEqual(\n      [\n        `grpcurl -import-path '/'`,\n        `-proto '/foo.proto'`,\n        `-d '{\"timestamp\": \\${[ faker \"timestamp\" ]}, \"foo\": \"bar\"}'`,\n        \"yaak.app\",\n      ].join(\" \\\\\\n  \"),\n    );\n  });\n});\n"
  },
  {
    "path": "plugins/action-copy-grpcurl/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/action-send-folder/package.json",
    "content": "{\n  \"name\": \"@yaak/action-send-folder\",\n  \"displayName\": \"Send All\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Send all HTTP requests in a folder sequentially in tree order\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins/action-send-folder\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  }\n}\n"
  },
  {
    "path": "plugins/action-send-folder/src/index.ts",
    "content": "import type { PluginDefinition } from \"@yaakapp/api\";\n\nexport const plugin: PluginDefinition = {\n  folderActions: [\n    {\n      label: \"Send All\",\n      icon: \"send_horizontal\",\n      async onSelect(ctx, args) {\n        const targetFolder = args.folder;\n\n        // Get all folders and HTTP requests\n        const [allFolders, allRequests] = await Promise.all([\n          ctx.folder.list(),\n          ctx.httpRequest.list(),\n        ]);\n\n        // Build the send order to match tree ordering:\n        // sort siblings by sortPriority then updatedAt, and traverse folders depth-first.\n        const compareByOrder = (\n          a: Pick<(typeof allFolders)[number], \"sortPriority\" | \"updatedAt\">,\n          b: Pick<(typeof allFolders)[number], \"sortPriority\" | \"updatedAt\">,\n        ) => {\n          if (a.sortPriority === b.sortPriority) {\n            return a.updatedAt > b.updatedAt ? 1 : -1;\n          }\n          return a.sortPriority - b.sortPriority;\n        };\n\n        const childrenByFolderId = new Map<\n          string,\n          Array<(typeof allFolders)[number] | (typeof allRequests)[number]>\n        >();\n        for (const folder of allFolders) {\n          if (folder.folderId == null) continue;\n          const children = childrenByFolderId.get(folder.folderId) ?? [];\n          children.push(folder);\n          childrenByFolderId.set(folder.folderId, children);\n        }\n        for (const request of allRequests) {\n          if (request.folderId == null) continue;\n          const children = childrenByFolderId.get(request.folderId) ?? [];\n          children.push(request);\n          childrenByFolderId.set(request.folderId, children);\n        }\n\n        const requestsToSend: typeof allRequests = [];\n        const collectRequests = (folderId: string) => {\n          const children = (childrenByFolderId.get(folderId) ?? []).slice().sort(compareByOrder);\n          for (const child of children) {\n            if (child.model === \"folder\") {\n              collectRequests(child.id);\n            } else if (child.model === \"http_request\") {\n              requestsToSend.push(child);\n            }\n          }\n        };\n        collectRequests(targetFolder.id);\n\n        if (requestsToSend.length === 0) {\n          await ctx.toast.show({\n            message: \"No requests in folder\",\n            icon: \"info\",\n            color: \"info\",\n          });\n          return;\n        }\n\n        // Send requests sequentially in the calculated folder order.\n        let successCount = 0;\n        let errorCount = 0;\n\n        for (const request of requestsToSend) {\n          try {\n            await ctx.httpRequest.send({ httpRequest: request });\n            successCount++;\n          } catch (error) {\n            errorCount++;\n            console.error(`Failed to send request ${request.id}:`, error);\n          }\n        }\n\n        // Show summary toast\n        if (errorCount === 0) {\n          await ctx.toast.show({\n            message: `Sent ${successCount} request${successCount !== 1 ? \"s\" : \"\"}`,\n            icon: \"send_horizontal\",\n            color: \"success\",\n          });\n        } else {\n          await ctx.toast.show({\n            message: `Sent ${successCount}, failed ${errorCount}`,\n            icon: \"alert_triangle\",\n            color: \"warning\",\n          });\n        }\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "plugins/action-send-folder/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/auth-apikey/package.json",
    "content": "{\n  \"name\": \"@yaak/auth-apikey\",\n  \"displayName\": \"API Key Authentication\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Authenticate requests using an API key\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins/auth-apikey\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  }\n}\n"
  },
  {
    "path": "plugins/auth-apikey/src/index.ts",
    "content": "import type { PluginDefinition } from \"@yaakapp/api\";\n\nexport const plugin: PluginDefinition = {\n  authentication: {\n    name: \"apikey\",\n    label: \"API Key\",\n    shortLabel: \"API Key\",\n    args: [\n      {\n        type: \"select\",\n        name: \"location\",\n        label: \"Behavior\",\n        defaultValue: \"header\",\n        options: [\n          { label: \"Insert Header\", value: \"header\" },\n          { label: \"Append Query Parameter\", value: \"query\" },\n        ],\n      },\n      {\n        type: \"text\",\n        name: \"key\",\n        label: \"Key\",\n        dynamic: (_ctx, { values }) => {\n          return values.location === \"query\"\n            ? {\n                label: \"Parameter Name\",\n                description: \"The name of the query parameter to add to the request\",\n              }\n            : {\n                label: \"Header Name\",\n                description: \"The name of the header to add to the request\",\n              };\n        },\n      },\n      {\n        type: \"text\",\n        name: \"value\",\n        label: \"API Key\",\n        optional: true,\n        password: true,\n      },\n    ],\n    async onApply(_ctx, { values }) {\n      const key = String(values.key ?? \"\");\n      const value = String(values.value ?? \"\");\n      const location = String(values.location);\n\n      if (location === \"query\") {\n        return { setQueryParameters: [{ name: key, value }] };\n      }\n      return { setHeaders: [{ name: key, value }] };\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/auth-apikey/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/auth-aws/README.md",
    "content": "# AWS Signature Version 4 Auth\n\nA plugin for authenticating AWS-compatible requests using the\n[AWS Signature Version 4 signing process](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html).  \nThis enables secure, signed requests to AWS services (or any S3-compatible APIs like\nCloudflare R2).\n\n![Screenshot of AWS SigV4 UI](screenshot.png)\n\n## Overview\n\nThis plugin provides AWS Signature authentication for API requests in Yaak. SigV4 is used\nby nearly all AWS APIs to verify the authenticity and integrity of requests using\ncryptographic signatures.\n\nWith this plugin, you can securely sign requests to AWS services such as S3, STS, Lambda,\nAPI Gateway, DynamoDB, and more. You can also authenticate against S3-compatible services\nlike **Cloudflare R2**, **MinIO**, or **Wasabi**.\n\n## How AWS Signature Version 4 Works\n\nSigV4 signs requests by creating a hash of key request components (method, URL, headers,\nand optionally the payload) using your AWS credentials. The resulting HMAC signature is\nadded in the `Authorization` header along with credential scope metadata.\n\nExample header:\n\n```\nAuthorization: AWS4-HMAC-SHA256 Credential=AKIA…/20251011/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abcdef123456…\n```\n\nEach request must include a timestamp (`X-Amz-Date`) and may include a session token if\nusing temporary credentials.\n\n## Configuration\n\nThe plugin presents the following fields:\n\n- **Access Key ID** – Your AWS access key identifier\n- **Secret Access Key** – The secret associated with the access key\n- **Session Token** _(optional)_ – Used for temporary or assumed-role credentials (treated as secret)\n- **Region** – AWS region (e.g., `us-east-1`)\n- **Service** – AWS service identifier (e.g., `sts`, `s3`, `execute-api`)\n\n## Usage\n\n1. Configure a request, folder, or workspace to use **AWS SigV4 Authentication**\n2. Enter your AWS credentials and target service/region\n3. The plugin will automatically sign outgoing requests with valid SigV4 headers\n"
  },
  {
    "path": "plugins/auth-aws/package.json",
    "content": "{\n  \"name\": \"@yaak/auth-aws\",\n  \"displayName\": \"AWS SigV4\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Authenticate requests using AWS SigV4 signing\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins/auth-aws\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  },\n  \"dependencies\": {\n    \"aws4\": \"^1.13.2\"\n  },\n  \"devDependencies\": {\n    \"@types/aws4\": \"^1.11.6\"\n  }\n}\n"
  },
  {
    "path": "plugins/auth-aws/src/index.ts",
    "content": "import { URL } from \"node:url\";\nimport type { PluginDefinition } from \"@yaakapp/api\";\nimport type { CallHttpAuthenticationResponse } from \"@yaakapp-internal/plugins\";\nimport type { Request } from \"aws4\";\nimport aws4 from \"aws4\";\n\nexport const plugin: PluginDefinition = {\n  authentication: {\n    name: \"awsv4\",\n    label: \"AWS Signature\",\n    shortLabel: \"AWS v4\",\n    args: [\n      { name: \"accessKeyId\", label: \"Access Key ID\", type: \"text\", password: true },\n      {\n        name: \"secretAccessKey\",\n        label: \"Secret Access Key\",\n        type: \"text\",\n        password: true,\n      },\n      {\n        name: \"service\",\n        label: \"Service Name\",\n        type: \"text\",\n        defaultValue: \"sts\",\n        placeholder: \"sts\",\n        description: \"The service that is receiving the request (sts, s3, sqs, ...)\",\n      },\n      {\n        name: \"region\",\n        label: \"Region\",\n        type: \"text\",\n        placeholder: \"us-east-1\",\n        description: \"The region that is receiving the request (defaults to us-east-1)\",\n        optional: true,\n      },\n      {\n        name: \"sessionToken\",\n        label: \"Session Token\",\n        type: \"text\",\n        password: true,\n        optional: true,\n        description: \"Only required if you are using temporary credentials\",\n      },\n    ],\n    onApply(_ctx, { values, ...args }): CallHttpAuthenticationResponse {\n      const accessKeyId = String(values.accessKeyId || \"\");\n      const secretAccessKey = String(values.secretAccessKey || \"\");\n      const sessionToken = String(values.sessionToken || \"\") || undefined;\n\n      const url = new URL(args.url);\n\n      const headers: NonNullable<Request[\"headers\"]> = {};\n      for (const headerName of [\"content-type\", \"host\", \"x-amz-date\", \"x-amz-security-token\"]) {\n        const v = args.headers.find((h) => h.name.toLowerCase() === headerName);\n        if (v != null) {\n          headers[headerName] = v.value;\n        }\n      }\n\n      const signature = aws4.sign(\n        {\n          host: url.host,\n          method: args.method,\n          path: url.pathname + (url.search || \"\"),\n          service: String(values.service || \"sts\"),\n          region: values.region ? String(values.region) : undefined,\n          headers,\n          doNotEncodePath: true,\n        },\n        {\n          accessKeyId,\n          secretAccessKey,\n          sessionToken,\n        },\n      );\n\n      if (signature.headers == null) {\n        return {};\n      }\n\n      return {\n        setHeaders: Object.entries(signature.headers)\n          .filter(([name]) => name !== \"content-type\") // Don't add this because we already have it\n          .map(([name, value]) => ({ name, value: String(value || \"\") })),\n      };\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/auth-aws/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/auth-basic/README.md",
    "content": "# Basic Authentication\n\nA simple Basic Authentication plugin that implements HTTP Basic Auth according\nto [RFC 7617](https://datatracker.ietf.org/doc/html/rfc7617), enabling secure\nauthentication with username and password credentials.\n\n![Screenshot of basic auth UI](screenshot.png)\n\n## Overview\n\nThis plugin provides HTTP Basic Authentication support for API requests in Yaak. Basic\nAuth is one of the most widely supported authentication methods, making it ideal for APIs\nthat require simple username/password authentication without the complexity of OAuth\nflows.\n\n## How Basic Authentication Works\n\nBasic Authentication encodes your username and password credentials using Base64 encoding\nand sends them in the `Authorization` header with each request. The format is:\n\n```\nAuthorization: Basic <base64-encoded-credentials>\n```\n\nWhere `<base64-encoded-credentials>` is the Base64 encoding of `username:password`.\n\n## Configuration\n\nThe plugin presents two fields:\n\n- **Username**: Username or user identifier\n- **Password**: Password or authentication token\n\n## Usage\n\n1. Configure the request, folder, or workspace to use Basic Authentication\n2. Enter your username and password in the authentication configuration\n3. The plugin will automatically add the proper `Authorization` header to your requests\n\n## Troubleshooting\n\n- **401 Unauthorized**: Verify your username and password are correct\n- **403 Forbidden**: Check if your account has the necessary permissions\n- **Connection Issues**: Ensure you're using HTTPS for secure transmission\n"
  },
  {
    "path": "plugins/auth-basic/package.json",
    "content": "{\n  \"name\": \"@yaak/auth-basic\",\n  \"displayName\": \"Basic Authentication\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Authenticate requests using Basic Auth\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins/auth-basic\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  }\n}\n"
  },
  {
    "path": "plugins/auth-basic/src/index.ts",
    "content": "import type { PluginDefinition } from \"@yaakapp/api\";\n\nexport const plugin: PluginDefinition = {\n  authentication: {\n    name: \"basic\",\n    label: \"Basic Auth\",\n    shortLabel: \"Basic\",\n    args: [\n      {\n        type: \"text\",\n        name: \"username\",\n        label: \"Username\",\n        optional: true,\n      },\n      {\n        type: \"text\",\n        name: \"password\",\n        label: \"Password\",\n        optional: true,\n        password: true,\n      },\n    ],\n    async onApply(_ctx, { values }) {\n      const username = values.username ?? \"\";\n      const password = values.password ?? \"\";\n      const value = `Basic ${Buffer.from(`${username}:${password}`).toString(\"base64\")}`;\n      return { setHeaders: [{ name: \"Authorization\", value }] };\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/auth-basic/tests/index.test.ts",
    "content": "import type { Context } from \"@yaakapp/api\";\nimport { describe, expect, test } from \"vite-plus/test\";\nimport { plugin } from \"../src\";\n\nconst ctx = {} as Context;\n\ndescribe(\"auth-basic\", () => {\n  test(\"Both username and password\", async () => {\n    expect(\n      await plugin.authentication?.onApply(ctx, {\n        values: { username: \"user\", password: \"pass\" },\n        headers: [],\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        contextId: \"111\",\n      }),\n    ).toEqual({\n      setHeaders: [\n        { name: \"Authorization\", value: `Basic ${Buffer.from(\"user:pass\").toString(\"base64\")}` },\n      ],\n    });\n  });\n\n  test(\"Empty password\", async () => {\n    expect(\n      await plugin.authentication?.onApply(ctx, {\n        values: { username: \"apikey\", password: \"\" },\n        headers: [],\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        contextId: \"111\",\n      }),\n    ).toEqual({\n      setHeaders: [\n        { name: \"Authorization\", value: `Basic ${Buffer.from(\"apikey:\").toString(\"base64\")}` },\n      ],\n    });\n  });\n\n  test(\"Missing password (undefined)\", async () => {\n    expect(\n      await plugin.authentication?.onApply(ctx, {\n        values: { username: \"apikey\" },\n        headers: [],\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        contextId: \"111\",\n      }),\n    ).toEqual({\n      setHeaders: [\n        { name: \"Authorization\", value: `Basic ${Buffer.from(\"apikey:\").toString(\"base64\")}` },\n      ],\n    });\n  });\n\n  test(\"Missing username (undefined)\", async () => {\n    expect(\n      await plugin.authentication?.onApply(ctx, {\n        values: { password: \"secret\" },\n        headers: [],\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        contextId: \"111\",\n      }),\n    ).toEqual({\n      setHeaders: [\n        { name: \"Authorization\", value: `Basic ${Buffer.from(\":secret\").toString(\"base64\")}` },\n      ],\n    });\n  });\n\n  test(\"No values (both undefined)\", async () => {\n    expect(\n      await plugin.authentication?.onApply(ctx, {\n        values: {},\n        headers: [],\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        contextId: \"111\",\n      }),\n    ).toEqual({\n      setHeaders: [\n        { name: \"Authorization\", value: `Basic ${Buffer.from(\":\").toString(\"base64\")}` },\n      ],\n    });\n  });\n});\n"
  },
  {
    "path": "plugins/auth-basic/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/auth-bearer/README.md",
    "content": "# Bearer Token Authentication Plugin\n\nA Bearer Token authentication plugin for Yaak that\nimplements [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750), enabling secure API\naccess using tokens, API keys, and other bearer credentials.\n\n![Screenshot of bearer auth UI](screenshot.png)\n\n## Overview\n\nThis plugin provides Bearer Token authentication support for your API requests in Yaak.\nBearer Token authentication is widely used in modern APIs, especially those following REST\nprinciples and OAuth 2.0 standards. It's the preferred method for APIs that issue access\ntokens, API keys, or other bearer credentials.\n\n## How Bearer Token Authentication Works\n\nBearer Token authentication sends your token in the `Authorization` header with each\nrequest using the Bearer scheme:\n\n```\nAuthorization: Bearer <your-token>\n```\n\nThe token is transmitted as-is without any additional encoding, making it simple and\nefficient for API authentication.\n\n## Configuration\n\nThe plugin requires only one field:\n\n- **Token**: Your bearer token, access token, API key, or other credential\n- **Prefix**: The prefix to use for the Authorization header, which will be of the\n  format \"<PREFIX> <TOKEN>\"\n\n## Usage\n\n1. Configure the request, folder, or workspace to use Bearer Authentication\n2. Enter the token and optional prefix in the authentication configuration\n3. The plugin will automatically add the proper `Authorization` header to your requests\n\n## Troubleshooting\n\n- **401 Unauthorized**: Verify your token is valid and not expired\n- **403 Forbidden**: Check if your token has the necessary permissions/scopes\n- **Invalid Token Format**: Ensure you're using the complete token without truncation\n- **Token Expiration**: Refresh or regenerate expired tokens\n"
  },
  {
    "path": "plugins/auth-bearer/package.json",
    "content": "{\n  \"name\": \"@yaak/auth-bearer\",\n  \"displayName\": \"Bearer Authentication\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Authenticate requests using bearer authentication\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins/auth-bearer\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  }\n}\n"
  },
  {
    "path": "plugins/auth-bearer/src/index.ts",
    "content": "import type { PluginDefinition } from \"@yaakapp/api\";\nimport type { CallHttpAuthenticationRequest } from \"@yaakapp-internal/plugins\";\n\nexport const plugin: PluginDefinition = {\n  authentication: {\n    name: \"bearer\",\n    label: \"Bearer Token\",\n    shortLabel: \"Bearer\",\n    args: [\n      {\n        type: \"text\",\n        name: \"token\",\n        label: \"Token\",\n        optional: true,\n        password: true,\n      },\n      {\n        type: \"text\",\n        name: \"prefix\",\n        label: \"Prefix\",\n        optional: true,\n        placeholder: \"\",\n        defaultValue: \"Bearer\",\n        description:\n          'The prefix to use for the Authorization header, which will be of the format \"<PREFIX> <TOKEN>\".',\n      },\n    ],\n    async onApply(_ctx, { values }) {\n      return { setHeaders: [generateAuthorizationHeader(values)] };\n    },\n  },\n};\n\nfunction generateAuthorizationHeader(values: CallHttpAuthenticationRequest[\"values\"]) {\n  const token = String(values.token || \"\").trim();\n  const prefix = String(values.prefix || \"\").trim();\n  const value = `${prefix} ${token}`.trim();\n  return { name: \"Authorization\", value };\n}\n"
  },
  {
    "path": "plugins/auth-bearer/tests/index.test.ts",
    "content": "import type { Context } from \"@yaakapp/api\";\nimport { describe, expect, test } from \"vite-plus/test\";\nimport { plugin } from \"../src\";\n\nconst ctx = {} as Context;\n\ndescribe(\"auth-bearer\", () => {\n  test(\"No values\", async () => {\n    expect(\n      await plugin.authentication?.onApply(ctx, {\n        values: {},\n        headers: [],\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        contextId: \"111\",\n      }),\n    ).toEqual({ setHeaders: [{ name: \"Authorization\", value: \"\" }] });\n  });\n\n  test(\"Only token\", async () => {\n    expect(\n      await plugin.authentication?.onApply(ctx, {\n        values: { token: \"my-token\" },\n        headers: [],\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        contextId: \"111\",\n      }),\n    ).toEqual({ setHeaders: [{ name: \"Authorization\", value: \"my-token\" }] });\n  });\n\n  test(\"Only prefix\", async () => {\n    expect(\n      await plugin.authentication?.onApply(ctx, {\n        values: { prefix: \"Hello\" },\n        headers: [],\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        contextId: \"111\",\n      }),\n    ).toEqual({ setHeaders: [{ name: \"Authorization\", value: \"Hello\" }] });\n  });\n\n  test(\"Prefix and token\", async () => {\n    expect(\n      await plugin.authentication?.onApply(ctx, {\n        values: { prefix: \"Hello\", token: \"my-token\" },\n        headers: [],\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        contextId: \"111\",\n      }),\n    ).toEqual({ setHeaders: [{ name: \"Authorization\", value: \"Hello my-token\" }] });\n  });\n\n  test(\"Extra spaces\", async () => {\n    expect(\n      await plugin.authentication?.onApply(ctx, {\n        values: { prefix: \"\\t Hello  \", token: \" \\nmy-token  \" },\n        headers: [],\n        url: \"https://yaak.app\",\n        method: \"POST\",\n        contextId: \"111\",\n      }),\n    ).toEqual({ setHeaders: [{ name: \"Authorization\", value: \"Hello my-token\" }] });\n  });\n});\n"
  },
  {
    "path": "plugins/auth-bearer/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/auth-jwt/README.md",
    "content": "# JSON Web Token (JWT) Authentication\n\nA [JSON Web Token](https://datatracker.ietf.org/doc/html/rfc7519) (JWT) authentication\nplugin that supports token generation, signing, and automatic header management.\n\n![Screenshot of JWT auth UI](screenshot.png)\n\n## Overview\n\nThis plugin provides JWT authentication support for API requests. JWT is a compact,\nURL-safe means of representing claims between two parties, commonly used for\nauthentication and information exchange in modern web applications and APIs.\n\n## How JWT Authentication Works\n\nJWT authentication involves creating a signed token containing claims about the user or\napplication. The token is sent in the `Authorization` header:\n\n```\nAuthorization: Bearer <jwt-token>\n```\n\nA JWT consists of three parts separated by dots:\n\n- **Header**: Contains the token type and signing algorithm\n- **Payload**: Contains the claims (user data, permissions, expiration, etc.)\n- **Signature**: Ensures the token hasn't been tampered with\n\n## Usage\n\n1. Configure the request, folder, or workspace to use JWT Authentication\n2. Set up your signing algorithm and secret/key\n3. Add custom JWT header fields if needed\n4. Configure the required claims for your JWT payload\n5. The plugin will generate, sign, and include the JWT in your requests\n\n## Common Use Cases\n\nJWT authentication is commonly used for:\n\n- **Microservices Authentication**: Service-to-service communication\n- **API Gateway Integration**: Authenticating with API gateways\n- **Single Sign-On (SSO)**: Sharing authentication across applications\n- **Stateless Authentication**: No server-side session storage required\n- **Mobile App APIs**: Secure authentication for mobile applications\n- **Third-party Integrations**: Authenticating with external services\n\n## Troubleshooting\n\n- **Invalid Signature**: Check your secret/key and algorithm configuration\n- **Token Expired**: Verify expiration time settings\n- **Invalid Claims**: Ensure required claims are properly configured\n- **Algorithm Mismatch**: Verify the algorithm matches what the API expects\n- **Key Format Issues**: Ensure RSA keys are in the correct PEM format\n"
  },
  {
    "path": "plugins/auth-jwt/package.json",
    "content": "{\n  \"name\": \"@yaak/auth-jwt\",\n  \"displayName\": \"JSON Web Tokens\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Authenticate requests using JSON web tokens (JWT)\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins/auth-jwt\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  },\n  \"dependencies\": {\n    \"jsonwebtoken\": \"^9.0.2\"\n  },\n  \"devDependencies\": {\n    \"@types/jsonwebtoken\": \"^9.0.7\"\n  }\n}\n"
  },
  {
    "path": "plugins/auth-jwt/src/index.ts",
    "content": "import type { PluginDefinition } from \"@yaakapp/api\";\nimport jwt from \"jsonwebtoken\";\n\nconst algorithms = [\n  \"HS256\",\n  \"HS384\",\n  \"HS512\",\n  \"RS256\",\n  \"RS384\",\n  \"RS512\",\n  \"PS256\",\n  \"PS384\",\n  \"PS512\",\n  \"ES256\",\n  \"ES384\",\n  \"ES512\",\n  \"none\",\n] as const;\n\nconst defaultAlgorithm = algorithms[0];\n\nexport const plugin: PluginDefinition = {\n  authentication: {\n    name: \"jwt\",\n    label: \"JWT Bearer\",\n    shortLabel: \"JWT\",\n    args: [\n      {\n        type: \"select\",\n        name: \"algorithm\",\n        label: \"Algorithm\",\n        hideLabel: true,\n        defaultValue: defaultAlgorithm,\n        options: algorithms.map((value) => ({ label: value === \"none\" ? \"None\" : value, value })),\n      },\n      {\n        type: \"text\",\n        name: \"secret\",\n        label: \"Secret or Private Key\",\n        password: true,\n        optional: true,\n        multiLine: true,\n      },\n      {\n        type: \"checkbox\",\n        name: \"secretBase64\",\n        label: \"Secret is base64 encoded\",\n      },\n      {\n        type: \"editor\",\n        name: \"payload\",\n        label: \"JWT Payload\",\n        language: \"json\",\n        defaultValue: '{\\n  \"foo\": \"bar\"\\n}',\n        placeholder: \"{ }\",\n      },\n      {\n        type: \"accordion\",\n        label: \"Advanced\",\n        inputs: [\n          {\n            type: \"editor\",\n            name: \"headers\",\n            label: \"JWT Header\",\n            description: \"Merged with auto-generated header fields like alg (e.g., kid)\",\n            language: \"json\",\n            defaultValue: \"{}\",\n            placeholder: \"{ }\",\n            optional: true,\n          },\n          {\n            type: \"select\",\n            name: \"location\",\n            label: \"Behavior\",\n            defaultValue: \"header\",\n            options: [\n              { label: \"Insert Header\", value: \"header\" },\n              { label: \"Append Query Parameter\", value: \"query\" },\n            ],\n          },\n          {\n            type: \"h_stack\",\n            inputs: [\n              {\n                type: \"text\",\n                name: \"name\",\n                label: \"Header Name\",\n                defaultValue: \"Authorization\",\n                optional: true,\n                description: \"The name of the header to add to the request\",\n              },\n              {\n                type: \"text\",\n                name: \"headerPrefix\",\n                label: \"Header Prefix\",\n                optional: true,\n                defaultValue: \"Bearer\",\n              },\n            ],\n            dynamic(_ctx, args) {\n              if (args.values.location === \"query\") {\n                return {\n                  hidden: true,\n                };\n              }\n            },\n          },\n          {\n            type: \"text\",\n            name: \"name\",\n            label: \"Parameter Name\",\n            description: \"The name of the query parameter to add to the request\",\n            defaultValue: \"token\",\n            optional: true,\n            dynamic(_ctx, args) {\n              if (args.values.location !== \"query\") {\n                return {\n                  hidden: true,\n                };\n              }\n            },\n          },\n        ],\n      },\n    ],\n    async onApply(_ctx, { values }) {\n      const { algorithm, secret: _secret, secretBase64, payload, headers } = values;\n      const secret = secretBase64 ? Buffer.from(`${_secret}`, \"base64\") : `${_secret}`;\n\n      const parsedHeaders = headers ? JSON.parse(`${headers}`) : undefined;\n\n      const token = jwt.sign(`${payload}`, secret, {\n        algorithm: algorithm as (typeof algorithms)[number],\n        // Extra header fields are merged with the auto-generated header (which includes alg)\n        header: parsedHeaders as jwt.JwtHeader | undefined,\n      });\n\n      if (values.location === \"query\") {\n        const paramName = String(values.name || \"token\");\n        return { setQueryParameters: [{ name: paramName, value: token }] };\n      }\n      const headerPrefix = values.headerPrefix != null ? values.headerPrefix : \"Bearer\";\n      const headerName = String(values.name || \"Authorization\");\n      const headerValue = `${headerPrefix} ${token}`.trim();\n      return { setHeaders: [{ name: headerName, value: headerValue }] };\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/auth-jwt/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/auth-ntlm/package.json",
    "content": "{\n  \"name\": \"@yaak/auth-ntlm\",\n  \"displayName\": \"NTLM Authentication\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Authenticate requests using NTLM authentication\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins/auth-ntlm\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  },\n  \"dependencies\": {\n    \"httpntlm\": \"^1.8.13\"\n  }\n}\n"
  },
  {
    "path": "plugins/auth-ntlm/src/index.ts",
    "content": "import type { PluginDefinition } from \"@yaakapp/api\";\n\nimport { ntlm } from \"httpntlm\";\n\nfunction extractNtlmChallenge(headers: Array<{ name: string; value: string }>): string | null {\n  const authValues = headers\n    .filter((h) => h.name.toLowerCase() === \"www-authenticate\")\n    .flatMap((h) => h.value.split(\",\"))\n    .map((v) => v.trim())\n    .filter(Boolean);\n\n  return authValues.find((v) => /^NTLM\\s+\\S+/i.test(v)) ?? null;\n}\n\nexport const plugin: PluginDefinition = {\n  authentication: {\n    name: \"windows\",\n    label: \"NTLM Auth\",\n    shortLabel: \"NTLM\",\n    args: [\n      {\n        type: \"banner\",\n        color: \"info\",\n        inputs: [\n          {\n            type: \"markdown\",\n            content:\n              \"NTLM is still in beta. Please submit any issues to [Feedback](https://yaak.app/feedback).\",\n          },\n        ],\n      },\n      {\n        type: \"text\",\n        name: \"username\",\n        label: \"Username\",\n        optional: true,\n      },\n      {\n        type: \"text\",\n        name: \"password\",\n        label: \"Password\",\n        optional: true,\n        password: true,\n      },\n      {\n        type: \"accordion\",\n        label: \"Advanced\",\n        inputs: [\n          { name: \"domain\", label: \"Domain\", type: \"text\", optional: true },\n          { name: \"workstation\", label: \"Workstation\", type: \"text\", optional: true },\n        ],\n      },\n    ],\n    async onApply(ctx, { values, method, url }) {\n      const username = values.username ? String(values.username) : undefined;\n      const password = values.password ? String(values.password) : undefined;\n      const domain = values.domain ? String(values.domain) : undefined;\n      const workstation = values.workstation ? String(values.workstation) : undefined;\n\n      const options = {\n        url,\n        username,\n        password,\n        workstation,\n        domain,\n      };\n\n      const type1 = ntlm.createType1Message(options);\n\n      const negotiateResponse = await ctx.httpRequest.send({\n        httpRequest: {\n          method,\n          url,\n          headers: [\n            { name: \"Authorization\", value: type1 },\n            { name: \"Connection\", value: \"keep-alive\" },\n          ],\n        },\n      });\n\n      const ntlmChallenge = extractNtlmChallenge(negotiateResponse.headers);\n      if (ntlmChallenge == null) {\n        throw new Error(\"Unable to find NTLM challenge in WWW-Authenticate response headers\");\n      }\n\n      const type2 = ntlm.parseType2Message(ntlmChallenge, (err: Error | null) => {\n        if (err != null) throw err;\n      });\n      const type3 = ntlm.createType3Message(type2, options);\n\n      return { setHeaders: [{ name: \"Authorization\", value: type3 }] };\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/auth-ntlm/src/modules.d.ts",
    "content": "declare module \"httpntlm\";\n"
  },
  {
    "path": "plugins/auth-ntlm/tests/index.test.ts",
    "content": "import type { Context } from \"@yaakapp/api\";\nimport { beforeEach, describe, expect, test, vi } from \"vite-plus/test\";\n\nconst ntlmMock = vi.hoisted(() => ({\n  createType1Message: vi.fn(),\n  parseType2Message: vi.fn(),\n  createType3Message: vi.fn(),\n}));\n\nvi.mock(\"httpntlm\", () => ({ ntlm: ntlmMock }));\n\nimport { plugin } from \"../src\";\n\ndescribe(\"auth-ntlm\", () => {\n  beforeEach(() => {\n    ntlmMock.createType1Message.mockReset();\n    ntlmMock.parseType2Message.mockReset();\n    ntlmMock.createType3Message.mockReset();\n    ntlmMock.createType1Message.mockReturnValue(\"NTLM TYPE1\");\n    // oxlint-disable-next-line no-explicit-any\n    ntlmMock.parseType2Message.mockReturnValue({} as any);\n    ntlmMock.createType3Message.mockReturnValue(\"NTLM TYPE3\");\n  });\n\n  test(\"uses NTLM challenge when Negotiate and NTLM headers are separate\", async () => {\n    const send = vi.fn().mockResolvedValue({\n      headers: [\n        { name: \"WWW-Authenticate\", value: \"Negotiate\" },\n        { name: \"WWW-Authenticate\", value: \"NTLM TlRMTVNTUAACAAAAAA==\" },\n      ],\n    });\n    const ctx = { httpRequest: { send } } as unknown as Context;\n\n    const result = await plugin.authentication?.onApply(ctx, {\n      values: {},\n      headers: [],\n      url: \"https://example.local/resource\",\n      method: \"GET\",\n      contextId: \"ctx\",\n    });\n\n    expect(ntlmMock.parseType2Message).toHaveBeenCalledWith(\n      \"NTLM TlRMTVNTUAACAAAAAA==\",\n      expect.any(Function),\n    );\n    expect(result).toEqual({ setHeaders: [{ name: \"Authorization\", value: \"NTLM TYPE3\" }] });\n  });\n\n  test(\"uses NTLM challenge when auth schemes are comma-separated in one header\", async () => {\n    const send = vi.fn().mockResolvedValue({\n      headers: [{ name: \"www-authenticate\", value: \"Negotiate, NTLM TlRMTVNTUAACAAAAAA==\" }],\n    });\n    const ctx = { httpRequest: { send } } as unknown as Context;\n\n    await plugin.authentication?.onApply(ctx, {\n      values: {},\n      headers: [],\n      url: \"https://example.local/resource\",\n      method: \"GET\",\n      contextId: \"ctx\",\n    });\n\n    expect(ntlmMock.parseType2Message).toHaveBeenCalledWith(\n      \"NTLM TlRMTVNTUAACAAAAAA==\",\n      expect.any(Function),\n    );\n  });\n\n  test(\"throws a clear error when NTLM challenge is missing\", async () => {\n    const send = vi.fn().mockResolvedValue({\n      headers: [{ name: \"WWW-Authenticate\", value: \"Negotiate\" }],\n    });\n    const ctx = { httpRequest: { send } } as unknown as Context;\n\n    await expect(\n      plugin.authentication?.onApply(ctx, {\n        values: {},\n        headers: [],\n        url: \"https://example.local/resource\",\n        method: \"GET\",\n        contextId: \"ctx\",\n      }),\n    ).rejects.toThrow(\"Unable to find NTLM challenge in WWW-Authenticate response headers\");\n  });\n});\n"
  },
  {
    "path": "plugins/auth-ntlm/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/auth-oauth1/package.json",
    "content": "{\n  \"name\": \"@yaak/auth-oauth1\",\n  \"displayName\": \"OAuth 1.0\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Authenticate requests using OAuth 1.0a\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins/auth-oauth1\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  },\n  \"dependencies\": {\n    \"oauth-1.0a\": \"^2.2.6\"\n  }\n}\n"
  },
  {
    "path": "plugins/auth-oauth1/src/index.ts",
    "content": "import crypto from \"node:crypto\";\nimport type { Context, GetHttpAuthenticationConfigRequest, PluginDefinition } from \"@yaakapp/api\";\nimport OAuth from \"oauth-1.0a\";\n\nconst signatures = {\n  HMAC_SHA1: \"HMAC-SHA1\",\n  HMAC_SHA256: \"HMAC-SHA256\",\n  HMAC_SHA512: \"HMAC-SHA512\",\n  RSA_SHA1: \"RSA-SHA1\",\n  RSA_SHA256: \"RSA-SHA256\",\n  RSA_SHA512: \"RSA-SHA512\",\n  PLAINTEXT: \"PLAINTEXT\",\n} as const;\nconst defaultSig = signatures.HMAC_SHA1;\n\nconst pkSigs = Object.values(signatures).filter((k) => k.startsWith(\"RSA-\"));\nconst nonPkSigs = Object.values(signatures).filter((k) => !pkSigs.includes(k));\n\ntype SigMethod = (typeof signatures)[keyof typeof signatures];\n\nfunction hiddenIfNot(\n  sigMethod: SigMethod[],\n  ...other: ((values: GetHttpAuthenticationConfigRequest[\"values\"]) => boolean)[]\n) {\n  return (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => {\n    const hasGrantType = sigMethod.find((t) => t === String(values.signatureMethod ?? defaultSig));\n    const hasOtherBools = other.every((t) => t(values));\n    const show = hasGrantType && hasOtherBools;\n    return { hidden: !show };\n  };\n}\n\nexport const plugin: PluginDefinition = {\n  authentication: {\n    name: \"oauth1\",\n    label: \"OAuth 1.0\",\n    shortLabel: \"OAuth 1\",\n    args: [\n      {\n        type: \"banner\",\n        color: \"info\",\n        inputs: [\n          {\n            type: \"markdown\",\n            content:\n              \"OAuth 1.0 is still in beta. Please submit any issues to [Feedback](https://yaak.app/feedback).\",\n          },\n        ],\n      },\n      {\n        name: \"signatureMethod\",\n        label: \"Signature Method\",\n        type: \"select\",\n        defaultValue: defaultSig,\n        options: Object.values(signatures).map((v) => ({ label: v, value: v })),\n      },\n      { name: \"consumerKey\", label: \"Consumer Key\", type: \"text\", password: true, optional: true },\n      {\n        name: \"consumerSecret\",\n        label: \"Consumer Secret\",\n        type: \"text\",\n        password: true,\n        optional: true,\n      },\n      {\n        name: \"tokenKey\",\n        label: \"Access Token\",\n        type: \"text\",\n        password: true,\n        optional: true,\n      },\n      {\n        name: \"tokenSecret\",\n        label: \"Token Secret\",\n        type: \"text\",\n        password: true,\n        optional: true,\n        dynamic: hiddenIfNot(nonPkSigs),\n      },\n      {\n        name: \"privateKey\",\n        label: \"Private Key (RSA-SHA1)\",\n        type: \"text\",\n        multiLine: true,\n        optional: true,\n        password: true,\n        placeholder:\n          \"-----BEGIN RSA PRIVATE KEY-----\\nPrivate key in PEM format\\n-----END RSA PRIVATE KEY-----\",\n        dynamic: hiddenIfNot(pkSigs),\n      },\n      {\n        type: \"accordion\",\n        label: \"Advanced\",\n        inputs: [\n          { name: \"callback\", label: \"Callback Url\", type: \"text\", optional: true },\n          { name: \"verifier\", label: \"Verifier\", type: \"text\", optional: true, password: true },\n          { name: \"timestamp\", label: \"Timestamp\", type: \"text\", optional: true },\n          { name: \"nonce\", label: \"Nonce\", type: \"text\", optional: true },\n          {\n            name: \"version\",\n            label: \"OAuth Version\",\n            type: \"text\",\n            optional: true,\n            defaultValue: \"1.0\",\n          },\n          { name: \"realm\", label: \"Realm\", type: \"text\", optional: true },\n        ],\n      },\n    ],\n\n    onApply(\n      _ctx,\n      { values, method, url },\n    ): {\n      setHeaders?: { name: string; value: string }[];\n      setQueryParameters?: { name: string; value: string }[];\n    } {\n      const consumerKey = String(values.consumerKey || \"\");\n      const consumerSecret = String(values.consumerSecret || \"\");\n\n      const signatureMethod = String(values.signatureMethod || signatures.HMAC_SHA1) as SigMethod;\n      const version = String(values.version || \"1.0\");\n      const realm = String(values.realm || \"\") || undefined;\n\n      const oauth = new OAuth({\n        consumer: { key: consumerKey, secret: consumerSecret },\n        signature_method: signatureMethod,\n        version,\n        hash_function: hashFunction(signatureMethod),\n        realm,\n      });\n\n      if (pkSigs.includes(signatureMethod)) {\n        oauth.getSigningKey = (tokenSecret?: string) => tokenSecret || \"\";\n      }\n\n      const requestUrl = new URL(url);\n\n      // Base request options passed to oauth-1.0a\n      const requestData: Omit<OAuth.RequestOptions, \"data\"> & {\n        data: Record<string, string | string[]>;\n      } = {\n        method,\n        url: requestUrl.toString(),\n        includeBodyHash: false,\n        data: {},\n      };\n\n      // (1) Include existing query params in signature base string\n      for (const key of requestUrl.searchParams.keys()) {\n        if (key.startsWith(\"oauth_\")) continue;\n        const all = requestUrl.searchParams.getAll(key);\n        const first = all[0];\n        if (first == null) continue;\n        requestData.data[key] = all.length > 1 ? all : first;\n      }\n\n      // (2) Manual oauth_* overrides\n      if (values.callback) requestData.data.oauth_callback = String(values.callback);\n      if (values.nonce) requestData.data.oauth_nonce = String(values.nonce);\n      if (values.timestamp) requestData.data.oauth_timestamp = String(values.timestamp);\n      if (values.verifier) requestData.data.oauth_verifier = String(values.verifier);\n\n      let token: OAuth.Token | { key: string } | undefined;\n\n      if (pkSigs.includes(signatureMethod)) {\n        token = {\n          key: String(values.tokenKey || \"\"),\n          secret: String(values.privateKey || \"\"),\n        };\n      } else if (values.tokenKey && values.tokenSecret) {\n        token = { key: String(values.tokenKey), secret: String(values.tokenSecret) };\n      } else if (values.tokenKey) {\n        token = { key: String(values.tokenKey) };\n      }\n\n      const authParams = oauth.authorize(requestData, token as OAuth.Token | undefined);\n      const { Authorization } = oauth.toHeader(authParams);\n      return { setHeaders: [{ name: \"Authorization\", value: Authorization }] };\n    },\n  },\n};\n\nfunction hashFunction(signatureMethod: SigMethod) {\n  switch (signatureMethod) {\n    case signatures.HMAC_SHA1:\n      return (base: string, key: string) =>\n        crypto.createHmac(\"sha1\", key).update(base).digest(\"base64\");\n    case signatures.HMAC_SHA256:\n      return (base: string, key: string) =>\n        crypto.createHmac(\"sha256\", key).update(base).digest(\"base64\");\n    case signatures.HMAC_SHA512:\n      return (base: string, key: string) =>\n        crypto.createHmac(\"sha512\", key).update(base).digest(\"base64\");\n    case signatures.RSA_SHA1:\n      return (base: string, privateKey: string) =>\n        crypto.createSign(\"RSA-SHA1\").update(base).sign(privateKey, \"base64\");\n    case signatures.RSA_SHA256:\n      return (base: string, privateKey: string) =>\n        crypto.createSign(\"RSA-SHA256\").update(base).sign(privateKey, \"base64\");\n    case signatures.RSA_SHA512:\n      return (base: string, privateKey: string) =>\n        crypto.createSign(\"RSA-SHA512\").update(base).sign(privateKey, \"base64\");\n    case signatures.PLAINTEXT:\n      return (base: string) => base;\n    default:\n      return (base: string, key: string) =>\n        crypto.createHmac(\"sha1\", key).update(base).digest(\"base64\");\n  }\n}\n"
  },
  {
    "path": "plugins/auth-oauth1/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/auth-oauth2/README.md",
    "content": "# OAuth 2.0 Authentication\n\nAn [OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749) authentication plugin that\nsupports multiple grant types and flows, enabling secure API authentication with OAuth 2.0\nproviders.\n\n![Screenshot of OAuth 2.0 auth UI](screenshot.png)\n\n## Overview\n\nThis plugin implements OAuth 2.0 authentication for requests, supporting the most common\nOAuth 2.0 grant types used in modern API integrations. It handles token management,\nautomatic refresh, and [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) (Proof Key\nfor Code Exchange) for enhanced security.\n\n## Supported Grant Types\n\n### Authorization Code Flow\n\nThe most secure and commonly used OAuth 2.0 flow for web applications.\n\n- Standard Authorization Code flow\n- Optional PKCE (Proof Key for Code Exchange) for enhanced security\n- Supports automatic token refresh\n\n### Client Credentials Flow\n\nIdeal for server-to-server authentication where no user interaction is required.\n\n### Implicit Flow\n\nLegacy flow for single-page applications (deprecated but still supported):\n\n- Direct access token retrieval\n- No refresh token support\n- Suitable for legacy integrations\n\n### Resource Owner Password Credentials Flow\n\nDirect username/password authentication.\n\n- User credentials are exchanged directly for tokens\n- Should only be used with trusted applications\n- Supports automatic token refresh\n\n## Features\n\n- **Automatic Token Management**: Handles token storage, expiration, and refresh\n  automatically\n- **PKCE Support**: Enhanced security for Authorization Code flow\n- **Token Persistence**: Stores tokens between sessions\n- **Flexible Configuration**: Supports custom authorization and token endpoints\n- **Scope Management**: Configure required OAuth scopes for your API\n- **Error Handling**: Comprehensive error handling and user feedback\n\n## Usage\n\n1. Configure the request, folder, or workspace to use OAuth 2.0 Authentication\n2. Select the appropriate grant type for your use case\n3. Fill in the required OAuth 2.0 parameters from your API provider\n4. The plugin will handle the authentication flow and token management automatically\n\n## Compatibility\n\nThis plugin is compatible with OAuth 2.0 providers including:\n\n- Google APIs\n- Microsoft Graph\n- GitHub API\n- Auth0\n- Okta\n- And many other OAuth 2.0 compliant services\n"
  },
  {
    "path": "plugins/auth-oauth2/package.json",
    "content": "{\n  \"name\": \"@yaak/auth-oauth2\",\n  \"displayName\": \"OAuth 2.0\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Authenticate requests using OAuth 2.0\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins/auth-oauth2\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  },\n  \"dependencies\": {\n    \"jsonwebtoken\": \"^9.0.2\"\n  },\n  \"devDependencies\": {\n    \"@types/jsonwebtoken\": \"^9.0.7\"\n  }\n}\n"
  },
  {
    "path": "plugins/auth-oauth2/src/callbackServer.ts",
    "content": "import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport http from \"node:http\";\nimport type { Context } from \"@yaakapp/api\";\n\nexport const HOSTED_CALLBACK_URL_BASE = \"https://oauth.yaak.app/redirect\";\nexport const DEFAULT_LOCALHOST_PORT = 8765;\nconst CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes\n\n/** Singleton: only one callback server runs at a time across all OAuth flows. */\nlet activeServer: CallbackServerResult | null = null;\n\nexport interface CallbackServerResult {\n  /** The port the server is listening on */\n  port: number;\n  /** The full redirect URI to register with the OAuth provider */\n  redirectUri: string;\n  /** Promise that resolves with the callback URL when received */\n  waitForCallback: () => Promise<string>;\n  /** Stop the server */\n  stop: () => void;\n}\n\n/**\n * Start a local HTTP server to receive OAuth callbacks.\n * Only one server runs at a time — if a previous server is still active,\n * it is stopped before starting the new one.\n * Returns the port, redirect URI, and a promise that resolves when the callback is received.\n */\nexport function startCallbackServer(options: {\n  /** Specific port to use, or 0 for random available port */\n  port?: number;\n  /** Path for the callback endpoint */\n  path?: string;\n  /** Timeout in milliseconds (default 5 minutes) */\n  timeoutMs?: number;\n}): Promise<CallbackServerResult> {\n  // Stop any previously active server before starting a new one\n  if (activeServer) {\n    console.log(\"[oauth2] Stopping previous callback server before starting new one\");\n    activeServer.stop();\n    activeServer = null;\n  }\n\n  const { port = 0, path = \"/callback\", timeoutMs = CALLBACK_TIMEOUT_MS } = options;\n\n  return new Promise((resolve, reject) => {\n    let callbackResolve: ((url: string) => void) | null = null;\n    let callbackReject: ((err: Error) => void) | null = null;\n    let timeoutHandle: ReturnType<typeof setTimeout> | null = null;\n    let stopped = false;\n\n    const server = http.createServer((req: IncomingMessage, res: ServerResponse) => {\n      const reqUrl = new URL(req.url ?? \"/\", `http://${req.headers.host}`);\n\n      // Only handle the callback path\n      if (reqUrl.pathname !== path && reqUrl.pathname !== `${path}/`) {\n        res.writeHead(404, { \"Content-Type\": \"text/plain\" });\n        res.end(\"Not Found\");\n        return;\n      }\n\n      if (req.method === \"POST\") {\n        // POST: read JSON body with the final callback URL and resolve\n        let body = \"\";\n        req.on(\"data\", (chunk: Buffer) => {\n          body += chunk.toString();\n        });\n        req.on(\"end\", () => {\n          try {\n            const { url: callbackUrl } = JSON.parse(body);\n            if (!callbackUrl || typeof callbackUrl !== \"string\") {\n              res.writeHead(400, { \"Content-Type\": \"text/plain\" });\n              res.end(\"Missing url in request body\");\n              return;\n            }\n\n            // Send success response\n            res.writeHead(200, { \"Content-Type\": \"text/plain\" });\n            res.end(\"OK\");\n\n            // Resolve the callback promise\n            if (callbackResolve) {\n              callbackResolve(callbackUrl);\n              callbackResolve = null;\n              callbackReject = null;\n            }\n\n            // Stop the server after a short delay to ensure response is sent\n            setTimeout(() => stopServer(), 100);\n          } catch {\n            res.writeHead(400, { \"Content-Type\": \"text/plain\" });\n            res.end(\"Invalid JSON\");\n          }\n        });\n        return;\n      }\n\n      // GET: serve intermediate page that reads the fragment and POSTs back\n      res.writeHead(200, { \"Content-Type\": \"text/html\" });\n      res.end(getFragmentForwardingHtml());\n    });\n\n    server.on(\"error\", (err: Error) => {\n      if (!stopped) {\n        reject(err);\n      }\n    });\n\n    const stopServer = () => {\n      if (stopped) return;\n      stopped = true;\n\n      // Clear the singleton reference\n      if (activeServer?.stop === stopServer) {\n        activeServer = null;\n      }\n\n      if (timeoutHandle) {\n        clearTimeout(timeoutHandle);\n        timeoutHandle = null;\n      }\n\n      server.close();\n\n      if (callbackReject) {\n        callbackReject(new Error(\"Callback server stopped\"));\n        callbackResolve = null;\n        callbackReject = null;\n      }\n    };\n\n    server.listen(port, \"127.0.0.1\", () => {\n      const address = server.address();\n      if (!address || typeof address === \"string\") {\n        reject(new Error(\"Failed to get server address\"));\n        return;\n      }\n\n      const actualPort = address.port;\n      const redirectUri = `http://127.0.0.1:${actualPort}${path}`;\n\n      console.log(`[oauth2] Callback server listening on ${redirectUri}`);\n\n      const result: CallbackServerResult = {\n        port: actualPort,\n        redirectUri,\n        waitForCallback: () => {\n          return new Promise<string>((res, rej) => {\n            if (stopped) {\n              rej(new Error(\"Callback server already stopped\"));\n              return;\n            }\n\n            callbackResolve = res;\n            callbackReject = rej;\n\n            // Set timeout\n            timeoutHandle = setTimeout(() => {\n              if (callbackReject) {\n                callbackReject(new Error(\"Authorization timed out\"));\n                callbackResolve = null;\n                callbackReject = null;\n              }\n              stopServer();\n            }, timeoutMs);\n          });\n        },\n        stop: stopServer,\n      };\n\n      activeServer = result;\n      resolve(result);\n    });\n  });\n}\n\n/**\n * Build the redirect URI for the hosted callback page.\n * The port is encoded in the URL path so the hosted page can redirect\n * to the local server without relying on query params (which some OAuth\n * providers strip). The default port is omitted for a cleaner URL.\n */\nexport function buildHostedCallbackRedirectUri(localPort: number): string {\n  if (localPort === DEFAULT_LOCALHOST_PORT) {\n    return HOSTED_CALLBACK_URL_BASE;\n  }\n  return `${HOSTED_CALLBACK_URL_BASE}/${localPort}`;\n}\n\n/**\n * Stop the active callback server if one is running.\n * Called during plugin dispose to ensure the server is cleaned up before the process exits.\n */\nexport function stopActiveServer(): void {\n  if (activeServer) {\n    console.log(\"[oauth2] Stopping active callback server during dispose\");\n    activeServer.stop();\n    activeServer = null;\n  }\n}\n\n/**\n * Open an authorization URL in the system browser, start a local callback server,\n * and wait for the OAuth provider to redirect back.\n *\n * Returns the raw callback URL and the redirect URI that was registered with the\n * OAuth provider (needed for token exchange).\n */\nexport async function getRedirectUrlViaExternalBrowser(\n  ctx: Context,\n  authorizationUrl: URL,\n  options: {\n    callbackType: \"localhost\" | \"hosted\";\n    callbackPort?: number;\n  },\n): Promise<{ callbackUrl: string; redirectUri: string }> {\n  const { callbackType, callbackPort } = options;\n\n  const port = callbackPort ?? DEFAULT_LOCALHOST_PORT;\n\n  console.log(`[oauth2] Starting callback server (type: ${callbackType}, port: ${port})`);\n\n  const server = await startCallbackServer({\n    port,\n    path: \"/callback\",\n  });\n\n  try {\n    // Determine the redirect URI to send to the OAuth provider\n    let oauthRedirectUri: string;\n\n    if (callbackType === \"hosted\") {\n      oauthRedirectUri = buildHostedCallbackRedirectUri(server.port);\n      console.log(\"[oauth2] Using hosted callback redirect:\", oauthRedirectUri);\n    } else {\n      oauthRedirectUri = server.redirectUri;\n      console.log(\"[oauth2] Using localhost callback redirect:\", oauthRedirectUri);\n    }\n\n    // Set the redirect URI on the authorization URL\n    authorizationUrl.searchParams.set(\"redirect_uri\", oauthRedirectUri);\n\n    const authorizationUrlStr = authorizationUrl.toString();\n    console.log(\"[oauth2] Opening external browser:\", authorizationUrlStr);\n\n    // Show toast to inform user\n    await ctx.toast.show({\n      message: \"Opening browser for authorization...\",\n      icon: \"info\",\n      timeout: 3000,\n    });\n\n    // Open the system browser\n    await ctx.window.openExternalUrl(authorizationUrlStr);\n\n    // Wait for the callback\n    console.log(\"[oauth2] Waiting for callback on\", server.redirectUri);\n    const callbackUrl = await server.waitForCallback();\n\n    console.log(\"[oauth2] Received callback:\", callbackUrl);\n\n    return { callbackUrl, redirectUri: oauthRedirectUri };\n  } finally {\n    server.stop();\n  }\n}\n\n/**\n * Intermediate HTML page that reads the URL fragment and _fragment query param,\n * reconstructs a proper OAuth callback URL, and POSTs it back to the server.\n *\n * Handles three cases:\n * - Localhost implicit: fragment is in location.hash (e.g. #access_token=...)\n * - Hosted implicit: fragment was converted to ?_fragment=... by the hosted redirect page\n * - Auth code: no fragment, code is already in query params\n */\nfunction getFragmentForwardingHtml(): string {\n  return `<!DOCTYPE html>\n<html>\n<head>\n  <title>Yaak</title>\n  <style>\n    * { box-sizing: border-box; margin: 0; padding: 0; }\n    body {\n      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;\n      display: flex;\n      justify-content: center;\n      align-items: center;\n      min-height: 100vh;\n      background: hsl(244,23%,14%);\n      color: hsl(245,23%,85%);\n    }\n    .container { text-align: center; }\n    .logo { display: block; width: 100px; height: 100px; margin: 0 auto 32px; border-radius: 50%; }\n    h1 { font-size: 28px; font-weight: 600; margin-bottom: 12px; }\n    p { font-size: 16px; color: hsl(245,18%,58%); }\n  </style>\n</head>\n<body>\n  <div class=\"container\">\n    <svg class=\"logo\" viewBox=\"0 0 1024 1024\" xmlns=\"http://www.w3.org/2000/svg\"><defs><linearGradient id=\"g\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0\" gradientUnits=\"userSpaceOnUse\" gradientTransform=\"matrix(649.94,712.03,-712.03,649.94,179.25,220.59)\"><stop offset=\"0\" stop-color=\"#4cc48c\"/><stop offset=\".5\" stop-color=\"#476cc9\"/><stop offset=\"1\" stop-color=\"#ba1ab7\"/></linearGradient></defs><rect x=\"0\" y=\"0\" width=\"1024\" height=\"1024\" fill=\"url(#g)\"/><g transform=\"matrix(0.822,0,0,0.822,91.26,91.26)\"><path d=\"M766.775,105.176C902.046,190.129 992.031,340.639 992.031,512C992.031,706.357 876.274,873.892 710,949.361C684.748,838.221 632.417,791.074 538.602,758.96C536.859,790.593 545.561,854.983 522.327,856.611C477.951,859.719 321.557,782.368 310.75,710.135C300.443,641.237 302.536,535.834 294.475,482.283C86.974,483.114 245.65,303.256 245.65,303.256L261.925,368.357L294.475,368.357C294.475,368.357 298.094,296.03 310.75,286.981C326.511,275.713 366.457,254.592 473.502,254.431C519.506,190.629 692.164,133.645 766.775,105.176ZM603.703,352.082C598.577,358.301 614.243,384.787 623.39,401.682C639.967,432.299 672.34,459.32 760.231,456.739C780.796,456.135 808.649,456.743 831.555,448.316C919.689,369.191 665.548,260.941 652.528,270.706C629.157,288.235 677.433,340.481 685.079,352.082C663.595,350.818 630.521,352.121 603.703,352.082ZM515.817,516.822C491.026,516.822 470.898,536.949 470.898,561.741C470.898,586.532 491.026,606.66 515.817,606.66C540.609,606.66 560.736,586.532 560.736,561.741C560.736,536.949 540.609,516.822 515.817,516.822ZM656.608,969.83C610.979,984.25 562.391,992.031 512,992.031C247.063,992.031 31.969,776.937 31.969,512C31.969,247.063 247.063,31.969 512,31.969C581.652,31.969 647.859,46.835 707.634,73.574C674.574,86.913 627.224,104.986 620,103.081C343.573,30.201 98.64,283.528 98.64,511.993C98.64,761.842 376.244,989.043 627.831,910C637.21,907.053 645.743,936.753 656.608,969.83Z\" fill=\"#fff\"/></g></svg>\n    <h1 id=\"title\">Authorizing...</h1>\n    <p id=\"message\">Please wait</p>\n  </div>\n  <script>\n  (function() {\n    var title = document.getElementById('title');\n    var message = document.getElementById('message');\n    var url = new URL(window.location.href);\n    var fragment = window.location.hash;\n    var fragmentParam = url.searchParams.get('_fragment');\n\n    // Build the final callback URL:\n    // 1. If _fragment query param exists (from hosted redirect), convert it back to a real fragment\n    // 2. If location.hash exists (direct localhost implicit), use it as-is\n    // 3. Otherwise (auth code flow), use the URL as-is with query params\n    if (fragmentParam) {\n      url.searchParams.delete('_fragment');\n      url.hash = fragmentParam;\n    } else if (fragment && fragment.length > 1) {\n      url.hash = fragment;\n    }\n\n    // POST the final URL back to the callback server\n    fetch(url.pathname, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({ url: url.toString() })\n    }).then(function(res) {\n      if (res.ok) {\n        title.textContent = 'Authorization Complete';\n        message.textContent = 'You may close this tab and return to Yaak';\n      } else {\n        title.textContent = 'Authorization Failed';\n        message.textContent = 'Something went wrong. Please try again.';\n      }\n    }).catch(function() {\n      title.textContent = 'Authorization Failed';\n      message.textContent = 'Something went wrong. Please try again.';\n    });\n  })();\n  </script>\n</body>\n</html>`;\n}\n"
  },
  {
    "path": "plugins/auth-oauth2/src/fetchAccessToken.ts",
    "content": "import { readFileSync } from \"node:fs\";\nimport type { Context, HttpRequest, HttpUrlParameter } from \"@yaakapp/api\";\nimport type { AccessTokenRawResponse } from \"./store\";\n\nexport async function fetchAccessToken(\n  ctx: Context,\n  args: {\n    clientId: string;\n    grantType: string;\n    accessTokenUrl: string;\n    scope: string | null;\n    audience: string | null;\n    params: HttpUrlParameter[];\n  } & ({ clientAssertion: string } | { clientSecret: string; credentialsInBody: boolean }),\n): Promise<AccessTokenRawResponse> {\n  const { clientId, grantType, accessTokenUrl, scope, audience, params } = args;\n  console.log(\"[oauth2] Getting access token\", accessTokenUrl);\n  const httpRequest: Partial<HttpRequest> = {\n    method: \"POST\",\n    url: accessTokenUrl,\n    bodyType: \"application/x-www-form-urlencoded\",\n    body: {\n      form: [{ name: \"grant_type\", value: grantType }, ...params],\n    },\n    headers: [\n      { name: \"User-Agent\", value: \"yaak\" },\n      {\n        name: \"Accept\",\n        value: \"application/x-www-form-urlencoded, application/json\",\n      },\n      { name: \"Content-Type\", value: \"application/x-www-form-urlencoded\" },\n    ],\n  };\n\n  if (scope) httpRequest.body?.form.push({ name: \"scope\", value: scope });\n  if (audience) httpRequest.body?.form.push({ name: \"audience\", value: audience });\n\n  if (\"clientAssertion\" in args) {\n    httpRequest.body?.form.push({ name: \"client_id\", value: clientId });\n    httpRequest.body?.form.push({\n      name: \"client_assertion_type\",\n      value: \"urn:ietf:params:oauth:client-assertion-type:jwt-bearer\",\n    });\n    httpRequest.body?.form.push({\n      name: \"client_assertion\",\n      value: args.clientAssertion,\n    });\n  } else if (args.credentialsInBody) {\n    httpRequest.body?.form.push({ name: \"client_id\", value: clientId });\n    httpRequest.body?.form.push({\n      name: \"client_secret\",\n      value: args.clientSecret,\n    });\n  } else {\n    const value = `Basic ${Buffer.from(`${clientId}:${args.clientSecret}`).toString(\"base64\")}`;\n    httpRequest.headers?.push({ name: \"Authorization\", value });\n  }\n\n  httpRequest.authenticationType = \"none\"; // Don't inherit workspace auth\n  const resp = await ctx.httpRequest.send({ httpRequest });\n\n  console.log(\"[oauth2] Got access token response\", resp.status);\n\n  if (resp.error) {\n    throw new Error(`Failed to fetch access token: ${resp.error}`);\n  }\n\n  const body = resp.bodyPath ? readFileSync(resp.bodyPath, \"utf8\") : \"\";\n\n  if (resp.status < 200 || resp.status >= 300) {\n    throw new Error(`Failed to fetch access token with status=${resp.status} and body=${body}`);\n  }\n\n  // oxlint-disable-next-line no-explicit-any\n  let response: any;\n  try {\n    response = JSON.parse(body);\n  } catch {\n    response = Object.fromEntries(new URLSearchParams(body));\n  }\n\n  if (response.error) {\n    throw new Error(`Failed to fetch access token with ${response.error}`);\n  }\n\n  return response;\n}\n"
  },
  {
    "path": "plugins/auth-oauth2/src/getOrRefreshAccessToken.ts",
    "content": "import { readFileSync } from \"node:fs\";\nimport type { Context, HttpRequest } from \"@yaakapp/api\";\nimport type { AccessToken, AccessTokenRawResponse, TokenStoreArgs } from \"./store\";\nimport { deleteToken, getToken, storeToken } from \"./store\";\nimport { isTokenExpired } from \"./util\";\n\nexport async function getOrRefreshAccessToken(\n  ctx: Context,\n  tokenArgs: TokenStoreArgs,\n  {\n    scope,\n    accessTokenUrl,\n    credentialsInBody,\n    clientId,\n    clientSecret,\n    forceRefresh,\n  }: {\n    scope: string | null;\n    accessTokenUrl: string;\n    credentialsInBody: boolean;\n    clientId: string;\n    clientSecret: string;\n    forceRefresh?: boolean;\n  },\n): Promise<AccessToken | null> {\n  const token = await getToken(ctx, tokenArgs);\n  if (token == null) {\n    return null;\n  }\n\n  const isExpired = isTokenExpired(token);\n\n  // Return the current access token if it's still valid\n  if (!isExpired && !forceRefresh) {\n    return token;\n  }\n\n  // Token is expired, but there's no refresh token :(\n  if (!token.response.refresh_token) {\n    return null;\n  }\n\n  // Access token is expired, so get a new one\n  const httpRequest: Partial<HttpRequest> = {\n    method: \"POST\",\n    url: accessTokenUrl,\n    bodyType: \"application/x-www-form-urlencoded\",\n    body: {\n      form: [\n        { name: \"grant_type\", value: \"refresh_token\" },\n        { name: \"refresh_token\", value: token.response.refresh_token },\n      ],\n    },\n    headers: [\n      { name: \"User-Agent\", value: \"yaak\" },\n      { name: \"Accept\", value: \"application/x-www-form-urlencoded, application/json\" },\n      { name: \"Content-Type\", value: \"application/x-www-form-urlencoded\" },\n    ],\n  };\n\n  if (scope) httpRequest.body?.form.push({ name: \"scope\", value: scope });\n\n  if (credentialsInBody) {\n    httpRequest.body?.form.push({ name: \"client_id\", value: clientId });\n    httpRequest.body?.form.push({ name: \"client_secret\", value: clientSecret });\n  } else {\n    const value = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString(\"base64\")}`;\n    httpRequest.headers?.push({ name: \"Authorization\", value });\n  }\n\n  httpRequest.authenticationType = \"none\"; // Don't inherit workspace auth\n  const resp = await ctx.httpRequest.send({ httpRequest });\n\n  if (resp.error) {\n    throw new Error(`Failed to refresh access token: ${resp.error}`);\n  }\n\n  if (resp.status >= 400 && resp.status < 500) {\n    // Client errors (4xx) indicate the refresh token is invalid, expired, or revoked\n    // Delete the token and return null to trigger a fresh authorization flow\n    console.log(\"[oauth2] Refresh token request failed with client error, deleting token\");\n    await deleteToken(ctx, tokenArgs);\n    return null;\n  }\n\n  const body = resp.bodyPath ? readFileSync(resp.bodyPath, \"utf8\") : \"\";\n\n  console.log(\"[oauth2] Got refresh token response\", resp.status);\n\n  if (resp.status < 200 || resp.status >= 300) {\n    throw new Error(`Failed to refresh access token with status=${resp.status} and body=${body}`);\n  }\n\n  // oxlint-disable-next-line no-explicit-any\n  let response: any;\n  try {\n    response = JSON.parse(body);\n  } catch {\n    response = Object.fromEntries(new URLSearchParams(body));\n  }\n\n  if (response.error) {\n    throw new Error(\n      `Failed to fetch access token with ${response.error} -> ${response.error_description}`,\n    );\n  }\n\n  const newResponse: AccessTokenRawResponse = {\n    ...response,\n    // Assign a new one or keep the old one,\n    refresh_token: response.refresh_token ?? token.response.refresh_token,\n  };\n\n  return storeToken(ctx, tokenArgs, newResponse);\n}\n"
  },
  {
    "path": "plugins/auth-oauth2/src/grants/authorizationCode.ts",
    "content": "import { createHash, randomBytes } from \"node:crypto\";\nimport type { Context } from \"@yaakapp/api\";\nimport { getRedirectUrlViaExternalBrowser } from \"../callbackServer\";\nimport { fetchAccessToken } from \"../fetchAccessToken\";\nimport { getOrRefreshAccessToken } from \"../getOrRefreshAccessToken\";\nimport type { AccessToken, TokenStoreArgs } from \"../store\";\nimport { getDataDirKey, storeToken } from \"../store\";\nimport { extractCode } from \"../util\";\n\nexport const PKCE_SHA256 = \"S256\";\nexport const PKCE_PLAIN = \"plain\";\nexport const DEFAULT_PKCE_METHOD = PKCE_SHA256;\n\nexport type CallbackType = \"localhost\" | \"hosted\";\n\nexport interface ExternalBrowserOptions {\n  useExternalBrowser: boolean;\n  callbackType: CallbackType;\n  /** Port for localhost callback (only used when callbackType is 'localhost') */\n  callbackPort?: number;\n}\n\nexport async function getAuthorizationCode(\n  ctx: Context,\n  contextId: string,\n  {\n    authorizationUrl: authorizationUrlRaw,\n    accessTokenUrl,\n    clientId,\n    clientSecret,\n    redirectUri,\n    scope,\n    state,\n    audience,\n    credentialsInBody,\n    pkce,\n    tokenName,\n    externalBrowser,\n  }: {\n    authorizationUrl: string;\n    accessTokenUrl: string;\n    clientId: string;\n    clientSecret: string;\n    redirectUri: string | null;\n    scope: string | null;\n    state: string | null;\n    audience: string | null;\n    credentialsInBody: boolean;\n    pkce: {\n      challengeMethod: string;\n      codeVerifier: string;\n    } | null;\n    tokenName: \"access_token\" | \"id_token\";\n    externalBrowser?: ExternalBrowserOptions;\n  },\n): Promise<AccessToken> {\n  const tokenArgs: TokenStoreArgs = {\n    contextId,\n    clientId,\n    accessTokenUrl,\n    authorizationUrl: authorizationUrlRaw,\n  };\n\n  const token = await getOrRefreshAccessToken(ctx, tokenArgs, {\n    accessTokenUrl,\n    scope,\n    clientId,\n    clientSecret,\n    credentialsInBody,\n  });\n  if (token != null) {\n    return token;\n  }\n\n  let authorizationUrl: URL;\n  try {\n    authorizationUrl = new URL(`${authorizationUrlRaw ?? \"\"}`);\n  } catch {\n    throw new Error(`Invalid authorization URL \"${authorizationUrlRaw}\"`);\n  }\n  authorizationUrl.searchParams.set(\"response_type\", \"code\");\n  authorizationUrl.searchParams.set(\"client_id\", clientId);\n  if (scope) authorizationUrl.searchParams.set(\"scope\", scope);\n  if (state) authorizationUrl.searchParams.set(\"state\", state);\n  if (audience) authorizationUrl.searchParams.set(\"audience\", audience);\n  if (pkce) {\n    authorizationUrl.searchParams.set(\n      \"code_challenge\",\n      pkceCodeChallenge(pkce.codeVerifier, pkce.challengeMethod),\n    );\n    authorizationUrl.searchParams.set(\"code_challenge_method\", pkce.challengeMethod);\n  }\n\n  let code: string;\n  let actualRedirectUri: string | null = redirectUri;\n\n  // Use external browser flow if enabled\n  if (externalBrowser?.useExternalBrowser) {\n    const result = await getRedirectUrlViaExternalBrowser(ctx, authorizationUrl, {\n      callbackType: externalBrowser.callbackType,\n      callbackPort: externalBrowser.callbackPort,\n    });\n    // Pass null to skip redirect URI matching — the callback came from our own local server\n    const extractedCode = extractCode(result.callbackUrl, null);\n    if (!extractedCode) {\n      throw new Error(\"No authorization code found in callback URL\");\n    }\n    code = extractedCode;\n    actualRedirectUri = result.redirectUri;\n  } else {\n    // Use embedded browser flow (original behavior)\n    if (redirectUri) {\n      authorizationUrl.searchParams.set(\"redirect_uri\", redirectUri);\n    }\n    code = await getCodeViaEmbeddedBrowser(ctx, contextId, authorizationUrl, redirectUri);\n  }\n\n  console.log(\"[oauth2] Code found\");\n  const response = await fetchAccessToken(ctx, {\n    grantType: \"authorization_code\",\n    accessTokenUrl,\n    clientId,\n    clientSecret,\n    scope,\n    audience,\n    credentialsInBody,\n    params: [\n      { name: \"code\", value: code },\n      ...(pkce ? [{ name: \"code_verifier\", value: pkce.codeVerifier }] : []),\n      ...(actualRedirectUri ? [{ name: \"redirect_uri\", value: actualRedirectUri }] : []),\n    ],\n  });\n\n  return storeToken(ctx, tokenArgs, response, tokenName);\n}\n\n/**\n * Get authorization code using the embedded browser window.\n * This is the original flow that monitors navigation events.\n */\nasync function getCodeViaEmbeddedBrowser(\n  ctx: Context,\n  contextId: string,\n  authorizationUrl: URL,\n  redirectUri: string | null,\n): Promise<string> {\n  const dataDirKey = await getDataDirKey(ctx, contextId);\n  const authorizationUrlStr = authorizationUrl.toString();\n  console.log(\"[oauth2] Authorizing via embedded browser\", authorizationUrlStr);\n\n  // oxlint-disable-next-line no-async-promise-executor -- Required for this pattern\n  return new Promise<string>(async (resolve, reject) => {\n    let foundCode = false;\n    const { close } = await ctx.window.openUrl({\n      dataDirKey,\n      url: authorizationUrlStr,\n      label: \"oauth-authorization-url\",\n      async onClose() {\n        if (!foundCode) {\n          reject(new Error(\"Authorization window closed\"));\n        }\n      },\n      async onNavigate({ url: urlStr }) {\n        let code: string | null;\n        try {\n          code = extractCode(urlStr, redirectUri);\n        } catch (err) {\n          reject(err);\n          close();\n          return;\n        }\n\n        if (!code) {\n          return;\n        }\n\n        foundCode = true;\n        close();\n        resolve(code);\n      },\n    });\n  });\n}\n\nexport function genPkceCodeVerifier() {\n  return encodeForPkce(randomBytes(32));\n}\n\nfunction pkceCodeChallenge(verifier: string, method: string) {\n  if (method === \"plain\") {\n    return verifier;\n  }\n\n  const hash = encodeForPkce(createHash(\"sha256\").update(verifier).digest());\n  return hash\n    .replace(/=/g, \"\") // Remove padding '='\n    .replace(/\\+/g, \"-\") // Replace '+' with '-'\n    .replace(/\\//g, \"_\"); // Replace '/' with '_'\n}\n\nfunction encodeForPkce(bytes: Buffer) {\n  return bytes\n    .toString(\"base64\")\n    .replace(/=/g, \"\") // Remove padding '='\n    .replace(/\\+/g, \"-\") // Replace '+' with '-'\n    .replace(/\\//g, \"_\"); // Replace '/' with '_'\n}\n"
  },
  {
    "path": "plugins/auth-oauth2/src/grants/clientCredentials.ts",
    "content": "import { createPrivateKey, randomUUID } from \"node:crypto\";\nimport type { Context } from \"@yaakapp/api\";\nimport jwt, { type Algorithm } from \"jsonwebtoken\";\nimport { fetchAccessToken } from \"../fetchAccessToken\";\nimport type { TokenStoreArgs } from \"../store\";\nimport { getToken, storeToken } from \"../store\";\nimport { isTokenExpired } from \"../util\";\n\nexport const jwtAlgorithms = [\n  \"HS256\",\n  \"HS384\",\n  \"HS512\",\n  \"RS256\",\n  \"RS384\",\n  \"RS512\",\n  \"PS256\",\n  \"PS384\",\n  \"PS512\",\n  \"ES256\",\n  \"ES384\",\n  \"ES512\",\n  \"none\",\n] as const;\n\nexport const defaultJwtAlgorithm = jwtAlgorithms[0];\n\n/**\n * Build a signed JWT for the client_assertion parameter (RFC 7523).\n *\n * The `secret` value is auto-detected as one of:\n *   - **JWK** – a JSON string containing a private-key object (has a `kty` field).\n *   - **PEM** – a string whose trimmed form starts with `-----`.\n *   - **HMAC secret** – anything else, used as-is for HS* algorithms.\n */\nfunction buildClientAssertionJwt(params: {\n  clientId: string;\n  accessTokenUrl: string;\n  secret: string;\n  algorithm: Algorithm;\n}): string {\n  const { clientId, accessTokenUrl, secret, algorithm } = params;\n\n  const isHmac = algorithm.startsWith(\"HS\") || algorithm === \"none\";\n\n  // Resolve the signing key depending on format\n  let signingKey: jwt.Secret;\n  let kid: string | undefined;\n\n  const trimmed = secret.trim();\n\n  if (isHmac) {\n    // HMAC algorithms use the raw secret (string or Buffer)\n    signingKey = secret;\n  } else if (trimmed.startsWith(\"{\")) {\n    // Looks like JSON - treat as JWK. There is surely a better way to detect JWK vs a raw secret, but this should work in most cases.\n    // oxlint-disable-next-line no-explicit-any\n    let jwk: any;\n    try {\n      jwk = JSON.parse(trimmed);\n    } catch {\n      throw new Error(\"Client Assertion secret looks like JSON but is not valid\");\n    }\n\n    kid = jwk?.kid;\n    signingKey = createPrivateKey({ key: jwk, format: \"jwk\" });\n  } else if (trimmed.startsWith(\"-----\")) {\n    // PEM-encoded key\n    signingKey = createPrivateKey({ key: trimmed, format: \"pem\" });\n  } else {\n    throw new Error(\n      \"Client Assertion secret must be a JWK JSON object, a PEM-encoded key \" +\n        \"(starting with -----), or a raw secret for HMAC algorithms.\",\n    );\n  }\n\n  const now = Math.floor(Date.now() / 1000);\n  const payload = {\n    iss: clientId,\n    sub: clientId,\n    aud: accessTokenUrl,\n    iat: now,\n    exp: now + 300, // 5 minutes\n    jti: randomUUID(),\n  };\n\n  // Build the JWT header; include \"kid\" when available\n  const header: jwt.JwtHeader = { alg: algorithm, typ: \"JWT\" };\n  if (kid) {\n    header.kid = kid;\n  }\n\n  return jwt.sign(JSON.stringify(payload), signingKey, {\n    algorithm: algorithm as jwt.Algorithm,\n    header,\n  });\n}\n\nexport async function getClientCredentials(\n  ctx: Context,\n  contextId: string,\n  {\n    accessTokenUrl,\n    clientId,\n    clientSecret,\n    scope,\n    audience,\n    credentialsInBody,\n    clientAssertionSecret,\n    clientAssertionSecretBase64,\n    clientCredentialsMethod,\n    clientAssertionAlgorithm,\n  }: {\n    accessTokenUrl: string;\n    clientId: string;\n    clientSecret: string;\n    scope: string | null;\n    audience: string | null;\n    credentialsInBody: boolean;\n    clientAssertionSecret: string;\n    clientAssertionSecretBase64: boolean;\n    clientCredentialsMethod: string;\n    clientAssertionAlgorithm: string;\n  },\n) {\n  const tokenArgs: TokenStoreArgs = {\n    contextId,\n    clientId,\n    accessTokenUrl,\n    authorizationUrl: null,\n  };\n  const token = await getToken(ctx, tokenArgs);\n  if (token && !isTokenExpired(token)) {\n    return token;\n  }\n\n  const common: Omit<\n    Parameters<typeof fetchAccessToken>[1],\n    \"clientAssertion\" | \"clientSecret\" | \"credentialsInBody\"\n  > = {\n    grantType: \"client_credentials\",\n    accessTokenUrl,\n    audience,\n    clientId,\n    scope,\n    params: [],\n  };\n\n  const fetchParams: Parameters<typeof fetchAccessToken>[1] =\n    clientCredentialsMethod === \"client_assertion\"\n      ? {\n          ...common,\n          clientAssertion: buildClientAssertionJwt({\n            clientId,\n            algorithm: clientAssertionAlgorithm as Algorithm,\n            accessTokenUrl,\n            secret: clientAssertionSecretBase64\n              ? Buffer.from(clientAssertionSecret, \"base64\").toString(\"utf-8\")\n              : clientAssertionSecret,\n          }),\n        }\n      : {\n          ...common,\n          clientSecret,\n          credentialsInBody,\n        };\n\n  const response = await fetchAccessToken(ctx, fetchParams);\n\n  return storeToken(ctx, tokenArgs, response);\n}\n"
  },
  {
    "path": "plugins/auth-oauth2/src/grants/implicit.ts",
    "content": "import type { Context } from \"@yaakapp/api\";\nimport { getRedirectUrlViaExternalBrowser } from \"../callbackServer\";\nimport type { AccessToken, AccessTokenRawResponse } from \"../store\";\nimport { getDataDirKey, getToken, storeToken } from \"../store\";\nimport { isTokenExpired } from \"../util\";\nimport type { ExternalBrowserOptions } from \"./authorizationCode\";\n\nexport async function getImplicit(\n  ctx: Context,\n  contextId: string,\n  {\n    authorizationUrl: authorizationUrlRaw,\n    responseType,\n    clientId,\n    redirectUri,\n    scope,\n    state,\n    audience,\n    tokenName,\n    externalBrowser,\n  }: {\n    authorizationUrl: string;\n    responseType: string;\n    clientId: string;\n    redirectUri: string | null;\n    scope: string | null;\n    state: string | null;\n    audience: string | null;\n    tokenName: \"access_token\" | \"id_token\";\n    externalBrowser?: ExternalBrowserOptions;\n  },\n): Promise<AccessToken> {\n  const tokenArgs = {\n    contextId,\n    clientId,\n    accessTokenUrl: null,\n    authorizationUrl: authorizationUrlRaw,\n  };\n  const token = await getToken(ctx, tokenArgs);\n  if (token != null && !isTokenExpired(token)) {\n    return token;\n  }\n\n  let authorizationUrl: URL;\n  try {\n    authorizationUrl = new URL(`${authorizationUrlRaw ?? \"\"}`);\n  } catch {\n    throw new Error(`Invalid authorization URL \"${authorizationUrlRaw}\"`);\n  }\n  authorizationUrl.searchParams.set(\"response_type\", responseType);\n  authorizationUrl.searchParams.set(\"client_id\", clientId);\n  if (scope) authorizationUrl.searchParams.set(\"scope\", scope);\n  if (state) authorizationUrl.searchParams.set(\"state\", state);\n  if (audience) authorizationUrl.searchParams.set(\"audience\", audience);\n  if (responseType.includes(\"id_token\")) {\n    authorizationUrl.searchParams.set(\n      \"nonce\",\n      String(Math.floor(Math.random() * 9999999999999) + 1),\n    );\n  }\n\n  let newToken: AccessToken;\n\n  // Use external browser flow if enabled\n  if (externalBrowser?.useExternalBrowser) {\n    const result = await getRedirectUrlViaExternalBrowser(ctx, authorizationUrl, {\n      callbackType: externalBrowser.callbackType,\n      callbackPort: externalBrowser.callbackPort,\n    });\n    newToken = await extractImplicitToken(ctx, result.callbackUrl, tokenArgs, tokenName);\n  } else {\n    // Use embedded browser flow (original behavior)\n    if (redirectUri) {\n      authorizationUrl.searchParams.set(\"redirect_uri\", redirectUri);\n    }\n    newToken = await getTokenViaEmbeddedBrowser(\n      ctx,\n      contextId,\n      authorizationUrl,\n      tokenArgs,\n      tokenName,\n    );\n  }\n\n  return newToken;\n}\n\n/**\n * Get token using the embedded browser window.\n * This is the original flow that monitors navigation events.\n */\nasync function getTokenViaEmbeddedBrowser(\n  ctx: Context,\n  contextId: string,\n  authorizationUrl: URL,\n  tokenArgs: {\n    contextId: string;\n    clientId: string;\n    accessTokenUrl: null;\n    authorizationUrl: string;\n  },\n  tokenName: \"access_token\" | \"id_token\",\n): Promise<AccessToken> {\n  const dataDirKey = await getDataDirKey(ctx, contextId);\n  const authorizationUrlStr = authorizationUrl.toString();\n  console.log(\"[oauth2] Authorizing via embedded browser (implicit)\", authorizationUrlStr);\n\n  // oxlint-disable-next-line no-async-promise-executor -- Required for this pattern\n  return new Promise<AccessToken>(async (resolve, reject) => {\n    let foundAccessToken = false;\n    const { close } = await ctx.window.openUrl({\n      dataDirKey,\n      url: authorizationUrlStr,\n      label: \"oauth-authorization-url\",\n      async onClose() {\n        if (!foundAccessToken) {\n          reject(new Error(\"Authorization window closed\"));\n        }\n      },\n      async onNavigate({ url: urlStr }) {\n        const url = new URL(urlStr);\n        if (url.searchParams.has(\"error\")) {\n          return reject(Error(`Failed to authorize: ${url.searchParams.get(\"error\")}`));\n        }\n\n        const hash = url.hash.slice(1);\n        const params = new URLSearchParams(hash);\n\n        const accessToken = params.get(tokenName);\n        if (!accessToken) {\n          return;\n        }\n        foundAccessToken = true;\n\n        // Close the window here, because we don't need it anymore\n        close();\n\n        const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse;\n        try {\n          resolve(storeToken(ctx, tokenArgs, response));\n        } catch (err) {\n          reject(err);\n        }\n      },\n    });\n  });\n}\n\n/**\n * Extract the implicit grant token from a callback URL and store it.\n */\nasync function extractImplicitToken(\n  ctx: Context,\n  callbackUrl: string,\n  tokenArgs: {\n    contextId: string;\n    clientId: string;\n    accessTokenUrl: null;\n    authorizationUrl: string;\n  },\n  tokenName: \"access_token\" | \"id_token\",\n): Promise<AccessToken> {\n  const url = new URL(callbackUrl);\n\n  // Check for errors\n  if (url.searchParams.has(\"error\")) {\n    throw new Error(`Failed to authorize: ${url.searchParams.get(\"error\")}`);\n  }\n\n  // Extract token from fragment\n  const hash = url.hash.slice(1);\n  const params = new URLSearchParams(hash);\n\n  // Also check query params (in case fragment was converted)\n  const accessToken = params.get(tokenName) ?? url.searchParams.get(tokenName);\n  if (!accessToken) {\n    throw new Error(`No ${tokenName} found in callback URL`);\n  }\n\n  // Build response from params (prefer fragment, fall back to query)\n  const response: AccessTokenRawResponse = {\n    access_token: params.get(\"access_token\") ?? url.searchParams.get(\"access_token\") ?? \"\",\n    token_type: params.get(\"token_type\") ?? url.searchParams.get(\"token_type\") ?? undefined,\n    expires_in: params.has(\"expires_in\")\n      ? parseInt(params.get(\"expires_in\") ?? \"0\", 10)\n      : url.searchParams.has(\"expires_in\")\n        ? parseInt(url.searchParams.get(\"expires_in\") ?? \"0\", 10)\n        : undefined,\n    scope: params.get(\"scope\") ?? url.searchParams.get(\"scope\") ?? undefined,\n  };\n\n  // Include id_token if present\n  const idToken = params.get(\"id_token\") ?? url.searchParams.get(\"id_token\");\n  if (idToken) {\n    response.id_token = idToken;\n  }\n\n  return storeToken(ctx, tokenArgs, response);\n}\n"
  },
  {
    "path": "plugins/auth-oauth2/src/grants/password.ts",
    "content": "import type { Context } from \"@yaakapp/api\";\nimport { fetchAccessToken } from \"../fetchAccessToken\";\nimport { getOrRefreshAccessToken } from \"../getOrRefreshAccessToken\";\nimport type { AccessToken, TokenStoreArgs } from \"../store\";\nimport { storeToken } from \"../store\";\n\nexport async function getPassword(\n  ctx: Context,\n  contextId: string,\n  {\n    accessTokenUrl,\n    clientId,\n    clientSecret,\n    username,\n    password,\n    credentialsInBody,\n    audience,\n    scope,\n  }: {\n    accessTokenUrl: string;\n    clientId: string;\n    clientSecret: string;\n    username: string;\n    password: string;\n    scope: string | null;\n    audience: string | null;\n    credentialsInBody: boolean;\n  },\n): Promise<AccessToken> {\n  const tokenArgs: TokenStoreArgs = {\n    contextId,\n    clientId,\n    accessTokenUrl,\n    authorizationUrl: null,\n  };\n  const token = await getOrRefreshAccessToken(ctx, tokenArgs, {\n    accessTokenUrl,\n    scope,\n    clientId,\n    clientSecret,\n    credentialsInBody,\n  });\n  if (token != null) {\n    return token;\n  }\n\n  const response = await fetchAccessToken(ctx, {\n    accessTokenUrl,\n    clientId,\n    clientSecret,\n    scope,\n    audience,\n    grantType: \"password\",\n    credentialsInBody,\n    params: [\n      { name: \"username\", value: username },\n      { name: \"password\", value: password },\n    ],\n  });\n\n  return storeToken(ctx, tokenArgs, response);\n}\n"
  },
  {
    "path": "plugins/auth-oauth2/src/index.ts",
    "content": "import type {\n  Context,\n  FormInputSelectOption,\n  GetHttpAuthenticationConfigRequest,\n  JsonPrimitive,\n  PluginDefinition,\n} from \"@yaakapp/api\";\nimport type { Algorithm } from \"jsonwebtoken\";\nimport {\n  buildHostedCallbackRedirectUri,\n  DEFAULT_LOCALHOST_PORT,\n  stopActiveServer,\n} from \"./callbackServer\";\nimport {\n  type CallbackType,\n  DEFAULT_PKCE_METHOD,\n  genPkceCodeVerifier,\n  getAuthorizationCode,\n  PKCE_PLAIN,\n  PKCE_SHA256,\n} from \"./grants/authorizationCode\";\nimport {\n  defaultJwtAlgorithm,\n  getClientCredentials,\n  jwtAlgorithms,\n} from \"./grants/clientCredentials\";\nimport { getImplicit } from \"./grants/implicit\";\nimport { getPassword } from \"./grants/password\";\nimport type { AccessToken, TokenStoreArgs } from \"./store\";\nimport { deleteToken, getToken, resetDataDirKey } from \"./store\";\n\ntype GrantType = \"authorization_code\" | \"implicit\" | \"password\" | \"client_credentials\";\n\nconst grantTypes: FormInputSelectOption[] = [\n  { label: \"Authorization Code\", value: \"authorization_code\" },\n  { label: \"Implicit\", value: \"implicit\" },\n  { label: \"Resource Owner Password Credential\", value: \"password\" },\n  { label: \"Client Credentials\", value: \"client_credentials\" },\n];\n\nconst defaultGrantType = grantTypes[0]?.value;\n\nfunction hiddenIfNot(\n  grantTypes: GrantType[],\n  ...other: ((values: GetHttpAuthenticationConfigRequest[\"values\"]) => boolean)[]\n) {\n  return (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => {\n    const hasGrantType = grantTypes.find((t) => t === String(values.grantType ?? defaultGrantType));\n    const hasOtherBools = other.every((t) => t(values));\n    const show = hasGrantType && hasOtherBools;\n    return { hidden: !show };\n  };\n}\n\nconst authorizationUrls = [\n  \"https://github.com/login/oauth/authorize\",\n  \"https://account.box.com/api/oauth2/authorize\",\n  \"https://accounts.google.com/o/oauth2/v2/auth\",\n  \"https://api.imgur.com/oauth2/authorize\",\n  \"https://bitly.com/oauth/authorize\",\n  \"https://gitlab.example.com/oauth/authorize\",\n  \"https://medium.com/m/oauth/authorize\",\n  \"https://public-api.wordpress.com/oauth2/authorize\",\n  \"https://slack.com/oauth/authorize\",\n  \"https://todoist.com/oauth/authorize\",\n  \"https://www.dropbox.com/oauth2/authorize\",\n  \"https://www.linkedin.com/oauth/v2/authorization\",\n  \"https://MY_SHOP.myshopify.com/admin/oauth/access_token\",\n  \"https://appcenter.intuit.com/app/connect/oauth2/authorize\",\n];\n\nconst accessTokenUrls = [\n  \"https://github.com/login/oauth/access_token\",\n  \"https://api-ssl.bitly.com/oauth/access_token\",\n  \"https://api.box.com/oauth2/token\",\n  \"https://api.dropboxapi.com/oauth2/token\",\n  \"https://api.imgur.com/oauth2/token\",\n  \"https://api.medium.com/v1/tokens\",\n  \"https://gitlab.example.com/oauth/token\",\n  \"https://public-api.wordpress.com/oauth2/token\",\n  \"https://slack.com/api/oauth.access\",\n  \"https://todoist.com/oauth/access_token\",\n  \"https://www.googleapis.com/oauth2/v4/token\",\n  \"https://www.linkedin.com/oauth/v2/accessToken\",\n  \"https://MY_SHOP.myshopify.com/admin/oauth/authorize\",\n  \"https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer\",\n];\n\nexport const plugin: PluginDefinition = {\n  dispose() {\n    stopActiveServer();\n  },\n  authentication: {\n    name: \"oauth2\",\n    label: \"OAuth 2.0\",\n    shortLabel: \"OAuth 2\",\n    actions: [\n      {\n        label: \"Copy Current Token\",\n        async onSelect(ctx, { contextId, values }) {\n          const tokenArgs: TokenStoreArgs = {\n            contextId,\n            authorizationUrl: stringArg(values, \"authorizationUrl\"),\n            accessTokenUrl: stringArg(values, \"accessTokenUrl\"),\n            clientId: stringArg(values, \"clientId\"),\n          };\n          const token = await getToken(ctx, tokenArgs);\n          if (token == null) {\n            await ctx.toast.show({\n              message: \"No token to copy\",\n              color: \"warning\",\n            });\n          } else {\n            await ctx.clipboard.copyText(token.response.access_token);\n            await ctx.toast.show({\n              message: \"Token copied to clipboard\",\n              icon: \"copy\",\n              color: \"success\",\n            });\n          }\n        },\n      },\n      {\n        label: \"Delete Token\",\n        async onSelect(ctx, { contextId, values }) {\n          const tokenArgs: TokenStoreArgs = {\n            contextId,\n            authorizationUrl: stringArg(values, \"authorizationUrl\"),\n            accessTokenUrl: stringArg(values, \"accessTokenUrl\"),\n            clientId: stringArg(values, \"clientId\"),\n          };\n          if (await deleteToken(ctx, tokenArgs)) {\n            await ctx.toast.show({\n              message: \"Token deleted\",\n              color: \"success\",\n            });\n          } else {\n            await ctx.toast.show({\n              message: \"No token to delete\",\n              color: \"warning\",\n            });\n          }\n        },\n      },\n      {\n        label: \"Clear Window Session\",\n        async onSelect(ctx, { contextId }) {\n          await resetDataDirKey(ctx, contextId);\n        },\n      },\n    ],\n    args: [\n      {\n        type: \"select\",\n        name: \"grantType\",\n        label: \"Grant Type\",\n        defaultValue: defaultGrantType,\n        options: grantTypes,\n      },\n      {\n        type: \"select\",\n        name: \"clientCredentialsMethod\",\n        label: \"Authentication Method\",\n        description:\n          '\"Client Secret\" sends client_secret. \\n' + '\"Client Assertion\" sends a signed JWT.',\n        defaultValue: \"client_secret\",\n        options: [\n          { label: \"Client Secret\", value: \"client_secret\" },\n          { label: \"Client Assertion\", value: \"client_assertion\" },\n        ],\n        dynamic: hiddenIfNot([\"client_credentials\"]),\n      },\n      {\n        type: \"text\",\n        name: \"clientId\",\n        label: \"Client ID\",\n        optional: true,\n      },\n      {\n        type: \"text\",\n        name: \"clientSecret\",\n        label: \"Client Secret\",\n        optional: true,\n        password: true,\n        dynamic: hiddenIfNot(\n          [\"authorization_code\", \"password\", \"client_credentials\"],\n          (values) => values.clientCredentialsMethod === \"client_secret\",\n        ),\n      },\n      {\n        type: \"select\",\n        name: \"clientAssertionAlgorithm\",\n        label: \"JWT Algorithm\",\n        defaultValue: defaultJwtAlgorithm,\n        options: jwtAlgorithms.map((value) => ({\n          label: value === \"none\" ? \"None\" : value,\n          value,\n        })),\n        dynamic: hiddenIfNot(\n          [\"client_credentials\"],\n          ({ clientCredentialsMethod }) => clientCredentialsMethod === \"client_assertion\",\n        ),\n      },\n      {\n        type: \"text\",\n        name: \"clientAssertionSecret\",\n        label: \"JWT Secret\",\n        description:\n          \"Can be HMAC, PEM or JWK. Make sure you pick the correct algorithm type above.\",\n        password: true,\n        optional: true,\n        multiLine: true,\n        dynamic: hiddenIfNot(\n          [\"client_credentials\"],\n          ({ clientCredentialsMethod }) => clientCredentialsMethod === \"client_assertion\",\n        ),\n      },\n      {\n        type: \"checkbox\",\n        name: \"clientAssertionSecretBase64\",\n        label: \"JWT secret is base64 encoded\",\n        dynamic: hiddenIfNot(\n          [\"client_credentials\"],\n          ({ clientCredentialsMethod }) => clientCredentialsMethod === \"client_assertion\",\n        ),\n      },\n      {\n        type: \"text\",\n        name: \"authorizationUrl\",\n        optional: true,\n        label: \"Authorization URL\",\n        dynamic: hiddenIfNot([\"authorization_code\", \"implicit\"]),\n        placeholder: authorizationUrls[0],\n        completionOptions: authorizationUrls.map((url) => ({\n          label: url,\n          value: url,\n        })),\n      },\n      {\n        type: \"text\",\n        name: \"accessTokenUrl\",\n        optional: true,\n        label: \"Access Token URL\",\n        placeholder: accessTokenUrls[0],\n        dynamic: hiddenIfNot([\"authorization_code\", \"password\", \"client_credentials\"]),\n        completionOptions: accessTokenUrls.map((url) => ({\n          label: url,\n          value: url,\n        })),\n      },\n      {\n        type: \"banner\",\n        inputs: [\n          {\n            type: \"checkbox\",\n            name: \"useExternalBrowser\",\n            label: \"Use External Browser\",\n            description:\n              \"Open authorization URL in your system browser instead of the embedded browser. \" +\n              \"Useful when the OAuth provider blocks embedded browsers or you need existing browser sessions.\",\n            dynamic: hiddenIfNot([\"authorization_code\", \"implicit\"]),\n          },\n          {\n            type: \"text\",\n            name: \"redirectUri\",\n            label: \"Redirect URI (can be any valid URL)\",\n            placeholder: \"https://mysite.example.com/oauth/callback\",\n            description:\n              \"URI the OAuth provider redirects to after authorization. Yaak intercepts this automatically in its embedded browser so any valid URI will work.\",\n            optional: true,\n            dynamic: hiddenIfNot(\n              [\"authorization_code\", \"implicit\"],\n              ({ useExternalBrowser }) => !useExternalBrowser,\n            ),\n          },\n          {\n            type: \"h_stack\",\n            inputs: [\n              {\n                type: \"select\",\n                name: \"callbackType\",\n                label: \"Callback Type\",\n                description:\n                  '\"Hosted Redirect\" uses an external Yaak-hosted endpoint. \"Localhost\" starts a local server to receive the callback.',\n                defaultValue: \"hosted\",\n                options: [\n                  { label: \"Hosted Redirect\", value: \"hosted\" },\n                  { label: \"Localhost\", value: \"localhost\" },\n                ],\n                dynamic: hiddenIfNot(\n                  [\"authorization_code\", \"implicit\"],\n                  ({ useExternalBrowser }) => !!useExternalBrowser,\n                ),\n              },\n              {\n                type: \"text\",\n                name: \"callbackPort\",\n                label: \"Callback Port\",\n                placeholder: `${DEFAULT_LOCALHOST_PORT}`,\n                description:\n                  \"Port for the local callback server. Defaults to \" +\n                  DEFAULT_LOCALHOST_PORT +\n                  \" if empty.\",\n                optional: true,\n                dynamic: hiddenIfNot(\n                  [\"authorization_code\", \"implicit\"],\n                  ({ useExternalBrowser }) => !!useExternalBrowser,\n                ),\n              },\n            ],\n          },\n          {\n            type: \"banner\",\n            color: \"info\",\n            inputs: [\n              {\n                type: \"markdown\",\n                content: \"Redirect URI to Register\",\n                async dynamic(_ctx, { values }) {\n                  const grantType = String(values.grantType ?? defaultGrantType);\n                  const useExternalBrowser = !!values.useExternalBrowser;\n                  const callbackType = (stringArg(values, \"callbackType\") ||\n                    \"localhost\") as CallbackType;\n\n                  // Only show for authorization_code and implicit with external browser enabled\n                  if (\n                    ![\"authorization_code\", \"implicit\"].includes(grantType) ||\n                    !useExternalBrowser\n                  ) {\n                    return { hidden: true };\n                  }\n\n                  // Compute the redirect URI based on callback type\n                  const port = intArg(values, \"callbackPort\") || DEFAULT_LOCALHOST_PORT;\n                  let redirectUri: string;\n                  if (callbackType === \"hosted\") {\n                    redirectUri = buildHostedCallbackRedirectUri(port);\n                  } else {\n                    redirectUri = `http://127.0.0.1:${port}/callback`;\n                  }\n\n                  return {\n                    hidden: false,\n                    content: `Register \\`${redirectUri}\\` as a redirect URI with your OAuth provider.`,\n                  };\n                },\n              },\n            ],\n          },\n        ],\n      },\n      {\n        type: \"text\",\n        name: \"state\",\n        label: \"State\",\n        optional: true,\n        dynamic: hiddenIfNot([\"authorization_code\", \"implicit\"]),\n      },\n      { type: \"text\", name: \"scope\", label: \"Scope\", optional: true },\n      { type: \"text\", name: \"audience\", label: \"Audience\", optional: true },\n      {\n        type: \"select\",\n        name: \"tokenName\",\n        label: \"Token for authorization\",\n        description:\n          'Select which token to send in the \"Authorization: Bearer\" header. Most APIs expect ' +\n          \"access_token, but some (like OpenID Connect) require id_token.\",\n        defaultValue: \"access_token\",\n        options: [\n          { label: \"access_token\", value: \"access_token\" },\n          { label: \"id_token\", value: \"id_token\" },\n        ],\n        dynamic: hiddenIfNot([\"authorization_code\", \"implicit\"]),\n      },\n      {\n        type: \"banner\",\n        inputs: [\n          {\n            type: \"checkbox\",\n            name: \"usePkce\",\n            label: \"Use PKCE\",\n            dynamic: hiddenIfNot([\"authorization_code\"]),\n          },\n          {\n            type: \"select\",\n            name: \"pkceChallengeMethod\",\n            label: \"Code Challenge Method\",\n            options: [\n              { label: \"SHA-256\", value: PKCE_SHA256 },\n              { label: \"Plain\", value: PKCE_PLAIN },\n            ],\n            defaultValue: DEFAULT_PKCE_METHOD,\n            dynamic: hiddenIfNot([\"authorization_code\"], ({ usePkce }) => !!usePkce),\n          },\n          {\n            type: \"text\",\n            name: \"pkceCodeChallenge\",\n            label: \"Code Verifier\",\n            placeholder: \"Automatically generated when not set\",\n            optional: true,\n            dynamic: hiddenIfNot([\"authorization_code\"], ({ usePkce }) => !!usePkce),\n          },\n        ],\n      },\n      {\n        type: \"h_stack\",\n        inputs: [\n          {\n            type: \"text\",\n            name: \"username\",\n            label: \"Username\",\n            optional: true,\n            dynamic: hiddenIfNot([\"password\"]),\n          },\n          {\n            type: \"text\",\n            name: \"password\",\n            label: \"Password\",\n            password: true,\n            optional: true,\n            dynamic: hiddenIfNot([\"password\"]),\n          },\n        ],\n      },\n      {\n        type: \"select\",\n        name: \"responseType\",\n        label: \"Response Type\",\n        defaultValue: \"token\",\n        options: [\n          { label: \"Access Token\", value: \"token\" },\n          { label: \"ID Token\", value: \"id_token\" },\n          { label: \"ID and Access Token\", value: \"id_token token\" },\n        ],\n        dynamic: hiddenIfNot([\"implicit\"]),\n      },\n      {\n        type: \"accordion\",\n        label: \"Advanced\",\n        inputs: [\n          {\n            type: \"text\",\n            name: \"headerName\",\n            label: \"Header Name\",\n            defaultValue: \"Authorization\",\n          },\n          {\n            type: \"text\",\n            name: \"headerPrefix\",\n            label: \"Header Prefix\",\n            optional: true,\n            defaultValue: \"Bearer\",\n          },\n          {\n            type: \"select\",\n            name: \"credentials\",\n            label: \"Send Credentials\",\n            defaultValue: \"body\",\n            options: [\n              { label: \"In Request Body\", value: \"body\" },\n              { label: \"As Basic Authentication\", value: \"basic\" },\n            ],\n            dynamic: (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => ({\n              hidden:\n                values.grantType === \"client_credentials\" &&\n                values.clientCredentialsMethod === \"client_assertion\",\n            }),\n          },\n        ],\n      },\n      {\n        type: \"accordion\",\n        label: \"Access Token Response\",\n        inputs: [],\n        async dynamic(ctx, { contextId, values }) {\n          const tokenArgs: TokenStoreArgs = {\n            contextId,\n            authorizationUrl: stringArg(values, \"authorizationUrl\"),\n            accessTokenUrl: stringArg(values, \"accessTokenUrl\"),\n            clientId: stringArg(values, \"clientId\"),\n          };\n          const token = await getToken(ctx, tokenArgs);\n          if (token == null) {\n            return { hidden: true };\n          }\n          return {\n            label: \"Access Token Response\",\n            inputs: [\n              {\n                type: \"editor\",\n                name: \"response\",\n                defaultValue: JSON.stringify(token.response, null, 2),\n                hideLabel: true,\n                readOnly: true,\n                language: \"json\",\n              },\n            ],\n          };\n        },\n      },\n    ],\n    async onApply(ctx, { values, contextId }) {\n      const headerPrefix = stringArg(values, \"headerPrefix\");\n      const grantType = stringArg(values, \"grantType\") as GrantType;\n      const credentialsInBody = values.credentials === \"body\";\n      const tokenName = values.tokenName === \"id_token\" ? \"id_token\" : \"access_token\";\n\n      // Build external browser options if enabled\n      const useExternalBrowser = !!values.useExternalBrowser;\n      const externalBrowserOptions = useExternalBrowser\n        ? {\n            useExternalBrowser: true,\n            callbackType: (stringArg(values, \"callbackType\") || \"localhost\") as CallbackType,\n            callbackPort: intArg(values, \"callbackPort\") ?? undefined,\n          }\n        : undefined;\n\n      let token: AccessToken;\n      if (grantType === \"authorization_code\") {\n        const authorizationUrl = stringArg(values, \"authorizationUrl\");\n        const accessTokenUrl = stringArg(values, \"accessTokenUrl\");\n        token = await getAuthorizationCode(ctx, contextId, {\n          accessTokenUrl:\n            accessTokenUrl === \"\" || accessTokenUrl.match(/^https?:\\/\\//)\n              ? accessTokenUrl\n              : `https://${accessTokenUrl}`,\n          authorizationUrl:\n            authorizationUrl === \"\" || authorizationUrl.match(/^https?:\\/\\//)\n              ? authorizationUrl\n              : `https://${authorizationUrl}`,\n          clientId: stringArg(values, \"clientId\"),\n          clientSecret: stringArg(values, \"clientSecret\"),\n          redirectUri: stringArgOrNull(values, \"redirectUri\"),\n          scope: stringArgOrNull(values, \"scope\"),\n          audience: stringArgOrNull(values, \"audience\"),\n          state: stringArgOrNull(values, \"state\"),\n          credentialsInBody,\n          pkce: values.usePkce\n            ? {\n                challengeMethod: stringArg(values, \"pkceChallengeMethod\") || DEFAULT_PKCE_METHOD,\n                codeVerifier: stringArg(values, \"pkceCodeVerifier\") || genPkceCodeVerifier(),\n              }\n            : null,\n          tokenName: tokenName,\n          externalBrowser: externalBrowserOptions,\n        });\n      } else if (grantType === \"implicit\") {\n        const authorizationUrl = stringArg(values, \"authorizationUrl\");\n        token = await getImplicit(ctx, contextId, {\n          authorizationUrl: authorizationUrl.match(/^https?:\\/\\//)\n            ? authorizationUrl\n            : `https://${authorizationUrl}`,\n          clientId: stringArg(values, \"clientId\"),\n          redirectUri: stringArgOrNull(values, \"redirectUri\"),\n          responseType: stringArg(values, \"responseType\"),\n          scope: stringArgOrNull(values, \"scope\"),\n          audience: stringArgOrNull(values, \"audience\"),\n          state: stringArgOrNull(values, \"state\"),\n          tokenName: tokenName,\n          externalBrowser: externalBrowserOptions,\n        });\n      } else if (grantType === \"client_credentials\") {\n        const accessTokenUrl = stringArg(values, \"accessTokenUrl\");\n        token = await getClientCredentials(ctx, contextId, {\n          accessTokenUrl: accessTokenUrl.match(/^https?:\\/\\//)\n            ? accessTokenUrl\n            : `https://${accessTokenUrl}`,\n          clientId: stringArg(values, \"clientId\"),\n          clientAssertionAlgorithm: stringArg(values, \"clientAssertionAlgorithm\") as Algorithm,\n          clientSecret: stringArg(values, \"clientSecret\"),\n          clientCredentialsMethod: stringArg(values, \"clientCredentialsMethod\"),\n          clientAssertionSecret: stringArg(values, \"clientAssertionSecret\"),\n          clientAssertionSecretBase64: !!values.clientAssertionSecretBase64,\n          scope: stringArgOrNull(values, \"scope\"),\n          audience: stringArgOrNull(values, \"audience\"),\n          credentialsInBody,\n        });\n      } else if (grantType === \"password\") {\n        const accessTokenUrl = stringArg(values, \"accessTokenUrl\");\n        token = await getPassword(ctx, contextId, {\n          accessTokenUrl: accessTokenUrl.match(/^https?:\\/\\//)\n            ? accessTokenUrl\n            : `https://${accessTokenUrl}`,\n          clientId: stringArg(values, \"clientId\"),\n          clientSecret: stringArg(values, \"clientSecret\"),\n          username: stringArg(values, \"username\"),\n          password: stringArg(values, \"password\"),\n          scope: stringArgOrNull(values, \"scope\"),\n          audience: stringArgOrNull(values, \"audience\"),\n          credentialsInBody,\n        });\n      } else {\n        throw new Error(`Invalid grant type ${String(grantType)}`);\n      }\n\n      const headerName = stringArg(values, \"headerName\") || \"Authorization\";\n      const headerValue = `${headerPrefix} ${token.response[tokenName] ?? \"\"}`.trim();\n      return { setHeaders: [{ name: headerName, value: headerValue }] };\n    },\n  },\n};\n\nfunction stringArgOrNull(\n  values: Record<string, JsonPrimitive | undefined>,\n  name: string,\n): string | null {\n  const arg = values[name];\n  if (arg == null || arg === \"\") return null;\n  return `${arg}`;\n}\n\nfunction stringArg(values: Record<string, JsonPrimitive | undefined>, name: string): string {\n  const arg = stringArgOrNull(values, name);\n  if (!arg) return \"\";\n  return arg;\n}\n\nfunction intArg(values: Record<string, JsonPrimitive | undefined>, name: string): number | null {\n  const arg = values[name];\n  if (arg == null || arg === \"\") return null;\n  const num = parseInt(`${arg}`, 10);\n  return Number.isNaN(num) ? null : num;\n}\n"
  },
  {
    "path": "plugins/auth-oauth2/src/store.ts",
    "content": "import { createHash } from \"node:crypto\";\nimport type { Context } from \"@yaakapp/api\";\n\nexport async function storeToken(\n  ctx: Context,\n  args: TokenStoreArgs,\n  response: AccessTokenRawResponse,\n  tokenName: \"access_token\" | \"id_token\" = \"access_token\",\n) {\n  if (!response[tokenName]) {\n    throw new Error(`${tokenName} not found in response ${Object.keys(response).join(\", \")}`);\n  }\n\n  const expiresAt = response.expires_in ? Date.now() + response.expires_in * 1000 : null;\n  const token: AccessToken = {\n    response,\n    expiresAt,\n  };\n  await ctx.store.set<AccessToken>(tokenStoreKey(args), token);\n  return token;\n}\n\nexport async function getToken(ctx: Context, args: TokenStoreArgs) {\n  return ctx.store.get<AccessToken>(tokenStoreKey(args));\n}\n\nexport async function deleteToken(ctx: Context, args: TokenStoreArgs) {\n  return ctx.store.delete(tokenStoreKey(args));\n}\n\nexport async function resetDataDirKey(ctx: Context, contextId: string) {\n  const key = new Date().toISOString();\n  return ctx.store.set<string>(dataDirStoreKey(contextId), key);\n}\n\nexport async function getDataDirKey(ctx: Context, contextId: string) {\n  const key = (await ctx.store.get<string>(dataDirStoreKey(contextId))) ?? \"default\";\n  return `${contextId}::${key}`;\n}\n\nexport interface TokenStoreArgs {\n  contextId: string;\n  clientId: string;\n  accessTokenUrl: string | null;\n  authorizationUrl: string | null;\n}\n\n/**\n * Generate a store key to use based on some arguments. The arguments will be normalized a bit to\n * account for slight variations (like domains with and without a protocol scheme).\n */\nfunction tokenStoreKey(args: TokenStoreArgs) {\n  const hash = createHash(\"md5\");\n  if (args.contextId) hash.update(args.contextId.trim());\n  if (args.clientId) hash.update(args.clientId.trim());\n  if (args.accessTokenUrl) hash.update(args.accessTokenUrl.trim().replace(/^https?:\\/\\//, \"\"));\n  if (args.authorizationUrl) hash.update(args.authorizationUrl.trim().replace(/^https?:\\/\\//, \"\"));\n  const key = hash.digest(\"hex\");\n  return [\"token\", key].join(\"::\");\n}\n\nfunction dataDirStoreKey(contextId: string) {\n  return [\"data_dir\", contextId].join(\"::\");\n}\n\nexport interface AccessToken {\n  response: AccessTokenRawResponse;\n  expiresAt: number | null;\n}\n\nexport interface AccessTokenRawResponse {\n  access_token: string;\n  id_token?: string;\n  token_type?: string;\n  expires_in?: number;\n  refresh_token?: string;\n  error?: string;\n  error_description?: string;\n  scope?: string;\n}\n"
  },
  {
    "path": "plugins/auth-oauth2/src/util.ts",
    "content": "import type { AccessToken } from \"./store\";\n\nexport function isTokenExpired(token: AccessToken) {\n  return token.expiresAt && Date.now() > token.expiresAt;\n}\n\nexport function extractCode(urlStr: string, redirectUri: string | null): string | null {\n  const url = new URL(urlStr);\n\n  if (!urlMatchesRedirect(url, redirectUri)) {\n    console.log(\"[oauth2] URL does not match redirect origin/path; skipping.\");\n    return null;\n  }\n\n  // Prefer query param; fall back to fragment if query lacks it\n\n  const query = url.searchParams;\n  const queryError = query.get(\"error\");\n  const queryDesc = query.get(\"error_description\");\n  const queryUri = query.get(\"error_uri\");\n\n  let hashParams: URLSearchParams | null = null;\n  if (url.hash && url.hash.length > 1) {\n    hashParams = new URLSearchParams(url.hash.slice(1));\n  }\n  const hashError = hashParams?.get(\"error\");\n  const hashDesc = hashParams?.get(\"error_description\");\n  const hashUri = hashParams?.get(\"error_uri\");\n\n  const error = queryError || hashError;\n  if (error) {\n    const desc = queryDesc || hashDesc;\n    const uri = queryUri || hashUri;\n    let message = `Failed to authorize: ${error}`;\n    if (desc) message += ` (${desc})`;\n    if (uri) message += ` [${uri}]`;\n    throw new Error(message);\n  }\n\n  const queryCode = query.get(\"code\");\n  if (queryCode) return queryCode;\n\n  const hashCode = hashParams?.get(\"code\");\n  if (hashCode) return hashCode;\n\n  console.log(\"[oauth2] Code not found\");\n  return null;\n}\n\nexport function urlMatchesRedirect(url: URL, redirectUrl: string | null): boolean {\n  if (!redirectUrl) return true;\n\n  let redirect: URL;\n  try {\n    redirect = new URL(redirectUrl);\n  } catch {\n    console.log(\"[oauth2] Invalid redirect URI; skipping.\");\n    return false;\n  }\n\n  const sameProtocol = url.protocol === redirect.protocol;\n\n  const sameHost = url.hostname.toLowerCase() === redirect.hostname.toLowerCase();\n\n  const normalizePort = (u: URL) =>\n    (u.protocol === \"https:\" && (!u.port || u.port === \"443\")) ||\n    (u.protocol === \"http:\" && (!u.port || u.port === \"80\"))\n      ? \"\"\n      : u.port;\n\n  const samePort = normalizePort(url) === normalizePort(redirect);\n\n  const normPath = (p: string) => {\n    const withLeading = p.startsWith(\"/\") ? p : `/${p}`;\n    // strip trailing slashes, keep root as \"/\"\n    return withLeading.replace(/\\/+$/g, \"\") || \"/\";\n  };\n\n  // Require redirect path to be a prefix of the navigated URL path\n  const urlPath = normPath(url.pathname);\n  const redirectPath = normPath(redirect.pathname);\n  const pathMatches = urlPath === redirectPath || urlPath.startsWith(`${redirectPath}/`);\n\n  return sameProtocol && sameHost && samePort && pathMatches;\n}\n"
  },
  {
    "path": "plugins/auth-oauth2/tests/util.test.ts",
    "content": "import { describe, expect, test } from \"vite-plus/test\";\nimport { extractCode } from \"../src/util\";\n\ndescribe(\"extractCode\", () => {\n  test(\"extracts code from query when same origin + path\", () => {\n    const url = \"https://app.example.com/cb?code=abc123&state=xyz\";\n    const redirect = \"https://app.example.com/cb\";\n    expect(extractCode(url, redirect)).toBe(\"abc123\");\n  });\n\n  test(\"extracts code from query with weird path\", () => {\n    const url = \"https://app.example.com/cbwithextra?code=abc123&state=xyz\";\n    const redirect = \"https://app.example.com/cb\";\n    expect(extractCode(url, redirect)).toBeNull();\n  });\n\n  test(\"allows trailing slash differences\", () => {\n    expect(extractCode(\"https://app.example.com/cb/?code=abc\", \"https://app.example.com/cb\")).toBe(\n      \"abc\",\n    );\n    expect(extractCode(\"https://app.example.com/cb?code=abc\", \"https://app.example.com/cb/\")).toBe(\n      \"abc\",\n    );\n  });\n\n  test(\"treats default ports as equal (https:443, http:80)\", () => {\n    expect(\n      extractCode(\"https://app.example.com/cb?code=abc\", \"https://app.example.com:443/cb\"),\n    ).toBe(\"abc\");\n    expect(extractCode(\"http://app.example.com/cb?code=abc\", \"http://app.example.com:80/cb\")).toBe(\n      \"abc\",\n    );\n  });\n\n  test(\"rejects different port\", () => {\n    expect(\n      extractCode(\"https://app.example.com/cb?code=abc\", \"https://app.example.com:8443/cb\"),\n    ).toBeNull();\n  });\n\n  test(\"rejects different hostname (including subdomain changes)\", () => {\n    expect(\n      extractCode(\"https://evil.example.com/cb?code=abc\", \"https://app.example.com/cb\"),\n    ).toBeNull();\n  });\n\n  test(\"requires path to start with redirect path (ignoring query/hash)\", () => {\n    // same origin but wrong path -> null\n    expect(\n      extractCode(\"https://app.example.com/other?code=abc\", \"https://app.example.com/cb\"),\n    ).toBeNull();\n\n    // deeper subpath under the redirect path -> allowed (prefix match)\n    expect(\n      extractCode(\"https://app.example.com/cb/deep?code=abc\", \"https://app.example.com/cb\"),\n    ).toBe(\"abc\");\n  });\n\n  test(\"works with custom schemes\", () => {\n    expect(extractCode(\"myapp://cb?code=abc\", \"myapp://cb\")).toBe(\"abc\");\n  });\n\n  test(\"prefers query over fragment when both present\", () => {\n    const url = \"https://app.example.com/cb?code=queryCode#code=hashCode\";\n    const redirect = \"https://app.example.com/cb\";\n    expect(extractCode(url, redirect)).toBe(\"queryCode\");\n  });\n\n  test(\"extracts code from fragment when query lacks code\", () => {\n    const url = \"https://app.example.com/cb#code=fromHash&state=xyz\";\n    const redirect = \"https://app.example.com/cb\";\n    expect(extractCode(url, redirect)).toBe(\"fromHash\");\n  });\n\n  test(\"returns null if no code present (query or fragment)\", () => {\n    const url = \"https://app.example.com/cb?state=only\";\n    const redirect = \"https://app.example.com/cb\";\n    expect(extractCode(url, redirect)).toBeNull();\n  });\n\n  test(\"returns null when provider reports an error\", () => {\n    const url = \"https://app.example.com/cb?error=access_denied&error_description=oopsy\";\n    const redirect = \"https://app.example.com/cb\";\n    expect(() => extractCode(url, redirect)).toThrow(\"Failed to authorize: access_denied\");\n  });\n\n  test(\"when redirectUri is null, extracts code from any URL\", () => {\n    expect(extractCode(\"https://random.example.com/whatever?code=abc\", null)).toBe(\"abc\");\n  });\n\n  test(\"handles extra params gracefully\", () => {\n    const url = \"https://app.example.com/cb?foo=1&bar=2&code=abc&baz=3\";\n    const redirect = \"https://app.example.com/cb\";\n    expect(extractCode(url, redirect)).toBe(\"abc\");\n  });\n\n  test(\"ignores fragment noise when code is in query\", () => {\n    const url = \"https://app.example.com/cb?code=abc#some=thing\";\n    const redirect = \"https://app.example.com/cb\";\n    expect(extractCode(url, redirect)).toBe(\"abc\");\n  });\n\n  // If you decide NOT to support fragment-based codes, flip these to expect null or mark as .skip\n  test(\"supports fragment-only code for response_mode=fragment providers\", () => {\n    const url = \"https://app.example.com/cb#state=xyz&code=abc\";\n    const redirect = \"https://app.example.com/cb\";\n    expect(extractCode(url, redirect)).toBe(\"abc\");\n  });\n});\n"
  },
  {
    "path": "plugins/auth-oauth2/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/filter-jsonpath/README.md",
    "content": "# JSONPath\n\nA filter plugin that enables [JSONPath](https://en.wikipedia.org/wiki/JSONPath)\nextraction and filtering for JSON responses, making it easy to extract specific values\nfrom complex JSON structures.\n\n![Screenshot of JSONPath filtering](screenshot.png)\n\n## Overview\n\nThis plugin provides JSONPath filtering for responses in Yaak. JSONPath is a query\nlanguage for JSON, similar to XPath for XML, that provides the ability to extract data\nfrom JSON documents using a simple, expressive syntax. This is useful for working with\ncomplex API responses where you need to only view a small subset of response data.\n\n## How JSONPath Works\n\nJSONPath uses a dot-notation syntax to navigate JSON structures:\n\n- `$` - Root element\n- `.` - Child element\n- `..` - Recursive descent\n- `*` - Wildcard\n- `[]` - Array index or filter\n\n## JSONPath Syntax Examples\n\n### Basic Navigation\n\n```\n$.store.book[0].title          # First book title\n$.store.book[*].author         # All book authors\n$.store.book[-1]               # Last book\n$.store.book[0,1]              # First two books\n$.store.book[0:2]              # First two books (slice)\n```\n\n### Filtering\n\n```\n$.store.book[?(@.price < 10)]           # Books under $10\n$.store.book[?(@.author == 'Tolkien')]  # Books by Tolkien\n$.store.book[?(@.category == 'fiction')] # Fiction books\n```\n\n### Recursive Search\n\n```\n$..author                      # All authors anywhere in the document\n$..book[2]                     # Third book anywhere\n$..price                       # All prices in the document\n```\n\n## Usage\n\n1. Make an API request that returns JSON data\n2. Below the response body, click the filter icon\n3. Enter a JSONPath expression\n4. View the extracted data in the results panel\n"
  },
  {
    "path": "plugins/filter-jsonpath/package.json",
    "content": "{\n  \"name\": \"@yaak/filter-jsonpath\",\n  \"displayName\": \"JSONPath Filter\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Filter JSON response data using JSONPath expressions\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins/filter-jsonpath\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  },\n  \"dependencies\": {\n    \"jsonpath-plus\": \"^10.3.0\"\n  },\n  \"devDependencies\": {\n    \"@types/jsonpath\": \"^0.2.4\"\n  }\n}\n"
  },
  {
    "path": "plugins/filter-jsonpath/src/index.ts",
    "content": "import type { PluginDefinition } from \"@yaakapp/api\";\nimport { JSONPath } from \"jsonpath-plus\";\n\nexport const plugin: PluginDefinition = {\n  filter: {\n    name: \"JSONPath\",\n    description: \"Filter JSONPath\",\n    onFilter(_ctx, args) {\n      const parsed = JSON.parse(args.payload);\n      try {\n        const filtered = JSONPath({ path: args.filter, json: parsed });\n        return { content: JSON.stringify(filtered, null, 2) };\n      } catch (err) {\n        return {\n          content: \"\",\n          error: `Invalid filter: ${err instanceof Error ? err.message : String(err)}`,\n        };\n      }\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/filter-jsonpath/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/filter-xpath/package.json",
    "content": "{\n  \"name\": \"@yaak/filter-xpath\",\n  \"displayName\": \"XPath Filter\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Filter response XML data using XPath expressions\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  },\n  \"dependencies\": {\n    \"@xmldom/xmldom\": \"^0.9.8\",\n    \"xpath\": \"^0.0.34\"\n  }\n}\n"
  },
  {
    "path": "plugins/filter-xpath/src/index.ts",
    "content": "/* oxlint-disable no-base-to-string */\nimport { DOMParser } from \"@xmldom/xmldom\";\nimport type { PluginDefinition } from \"@yaakapp/api\";\nimport xpath from \"xpath\";\n\nexport const plugin: PluginDefinition = {\n  filter: {\n    name: \"XPath\",\n    description: \"Filter XPath\",\n    onFilter(_ctx, args) {\n      // oxlint-disable-next-line no-explicit-any\n      const doc: any = new DOMParser().parseFromString(args.payload, \"text/xml\");\n      try {\n        const result = xpath.select(args.filter, doc, false);\n        if (Array.isArray(result)) {\n          return { content: result.map((r) => String(r)).join(\"\\n\") };\n        }\n        // Not sure what cases this happens in (?)\n        return { content: String(result) };\n      } catch (err) {\n        return {\n          content: \"\",\n          error: `Invalid filter: ${err instanceof Error ? err.message : String(err)}`,\n        };\n      }\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/filter-xpath/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/importer-curl/package.json",
    "content": "{\n  \"name\": \"@yaak/importer-curl\",\n  \"displayName\": \"cURL Importer\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Import requests from cURL commands\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  },\n  \"dependencies\": {\n    \"shlex\": \"^3.0.0\"\n  }\n}\n"
  },
  {
    "path": "plugins/importer-curl/src/index.ts",
    "content": "import type {\n  Context,\n  Environment,\n  Folder,\n  HttpRequest,\n  HttpUrlParameter,\n  PluginDefinition,\n  Workspace,\n} from \"@yaakapp/api\";\nimport { split } from \"shlex\";\n\ntype AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;\n\ninterface ExportResources {\n  workspaces: AtLeast<Workspace, \"name\" | \"id\" | \"model\">[];\n  environments: AtLeast<Environment, \"name\" | \"id\" | \"model\" | \"workspaceId\">[];\n  httpRequests: AtLeast<HttpRequest, \"name\" | \"id\" | \"model\" | \"workspaceId\">[];\n  folders: AtLeast<Folder, \"name\" | \"id\" | \"model\" | \"workspaceId\">[];\n}\n\nconst DATA_FLAGS = [\"d\", \"data\", \"data-raw\", \"data-urlencode\", \"data-binary\", \"data-ascii\"];\nconst SUPPORTED_FLAGS = [\n  [\"cookie\", \"b\"],\n  [\"d\", \"data\"], // Add url encoded data\n  [\"data-ascii\"],\n  [\"data-binary\"],\n  [\"data-raw\"],\n  [\"data-urlencode\"],\n  [\"digest\"], // Apply auth as digest\n  [\"form\", \"F\"], // Add multipart data\n  [\"get\", \"G\"], // Put the post data in the URL\n  [\"header\", \"H\"],\n  [\"request\", \"X\"], // Request method\n  [\"url\"], // Specify the URL explicitly\n  [\"url-query\"],\n  [\"user\", \"u\"], // Authentication\n  DATA_FLAGS,\n].flat();\n\nconst BOOLEAN_FLAGS = [\"G\", \"get\", \"digest\"];\n\ntype FlagValue = string | boolean;\n\ntype FlagsByName = Record<string, FlagValue[]>;\n\nexport const plugin: PluginDefinition = {\n  importer: {\n    name: \"cURL\",\n    description: \"Import cURL commands\",\n    onImport(_ctx: Context, args: { text: string }) {\n      // oxlint-disable-next-line no-explicit-any\n      return convertCurl(args.text) as any;\n    },\n  },\n};\n\n/**\n * Splits raw input into individual shell command strings.\n * Handles line continuations, semicolons, and newline-separated curl commands.\n */\nfunction splitCommands(rawData: string): string[] {\n  // Join line continuations (backslash-newline, and backslash-CRLF for Windows)\n  const joined = rawData.replace(/\\\\\\r?\\n/g, \" \");\n\n  // Count consecutive backslashes immediately before position i.\n  // An even count means the quote at i is NOT escaped; odd means it IS escaped.\n  function isEscaped(i: number): boolean {\n    let backslashes = 0;\n    let j = i - 1;\n    while (j >= 0 && joined[j] === \"\\\\\") {\n      backslashes++;\n      j--;\n    }\n    return backslashes % 2 !== 0;\n  }\n\n  // Split on semicolons and newlines to separate commands\n  const commands: string[] = [];\n  let current = \"\";\n  let inSingleQuote = false;\n  let inDoubleQuote = false;\n  let inDollarQuote = false;\n\n  for (let i = 0; i < joined.length; i++) {\n    if (joined[i] === undefined) break; // Make TS happy\n\n    const ch = joined[i];\n    const next = joined[i + 1];\n\n    // Track quoting state to avoid splitting inside quoted strings\n    if (!inDoubleQuote && !inDollarQuote && ch === \"'\" && !inSingleQuote) {\n      inSingleQuote = true;\n      current += ch;\n      continue;\n    }\n    if (inSingleQuote && ch === \"'\") {\n      inSingleQuote = false;\n      current += ch;\n      continue;\n    }\n    if (!inSingleQuote && !inDollarQuote && ch === '\"' && !inDoubleQuote) {\n      inDoubleQuote = true;\n      current += ch;\n      continue;\n    }\n    if (inDoubleQuote && ch === '\"' && !isEscaped(i)) {\n      inDoubleQuote = false;\n      current += ch;\n      continue;\n    }\n    if (!inSingleQuote && !inDoubleQuote && !inDollarQuote && ch === \"$\" && next === \"'\") {\n      inDollarQuote = true;\n      current += ch + next;\n      i++; // Skip the opening quote\n      continue;\n    }\n    if (inDollarQuote && ch === \"'\" && !isEscaped(i)) {\n      inDollarQuote = false;\n      current += ch;\n      continue;\n    }\n\n    const inQuote = inSingleQuote || inDoubleQuote || inDollarQuote;\n\n    // Split on ;, newline, or CRLF when not inside quotes and not escaped\n    if (\n      !inQuote &&\n      !isEscaped(i) &&\n      (ch === \";\" || ch === \"\\n\" || (ch === \"\\r\" && next === \"\\n\"))\n    ) {\n      if (ch === \"\\r\") i++; // Skip the \\n in \\r\\n\n      if (current.trim()) {\n        commands.push(current.trim());\n      }\n      current = \"\";\n      continue;\n    }\n\n    current += ch;\n  }\n\n  if (current.trim()) {\n    commands.push(current.trim());\n  }\n\n  return commands;\n}\n\nexport function convertCurl(rawData: string) {\n  if (!rawData.match(/^\\s*curl /)) {\n    return null;\n  }\n\n  const commands: string[][] = splitCommands(rawData).map((cmd) => {\n    const tokens = split(cmd);\n\n    // Break up squished arguments like `-XPOST` into `-X POST`\n    return tokens.flatMap((token) => {\n      if (token.startsWith(\"-\") && !token.startsWith(\"--\") && token.length > 2) {\n        return [token.slice(0, 2), token.slice(2)];\n      }\n      return token;\n    });\n  });\n\n  const workspace: ExportResources[\"workspaces\"][0] = {\n    model: \"workspace\",\n    id: generateId(\"workspace\"),\n    name: \"Curl Import\",\n  };\n\n  const requests: ExportResources[\"httpRequests\"] = commands\n    .filter((command) => command[0] === \"curl\")\n    .map((v) => importCommand(v, workspace.id));\n\n  return {\n    resources: {\n      httpRequests: requests,\n      workspaces: [workspace],\n    },\n  };\n}\n\nfunction importCommand(parseEntries: string[], workspaceId: string) {\n  // ~~~~~~~~~~~~~~~~~~~~~ //\n  // Collect all the flags //\n  // ~~~~~~~~~~~~~~~~~~~~~ //\n  const flagsByName: FlagsByName = {};\n  const singletons: string[] = [];\n\n  // Start at 1 so we can skip the ^curl part\n  for (let i = 1; i < parseEntries.length; i++) {\n    let parseEntry = parseEntries[i];\n    if (typeof parseEntry === \"string\") {\n      parseEntry = parseEntry.trim();\n    }\n\n    if (typeof parseEntry === \"string\" && parseEntry.match(/^-{1,2}[\\w-]+/)) {\n      const isSingleDash = parseEntry[0] === \"-\" && parseEntry[1] !== \"-\";\n      let name = parseEntry.replace(/^-{1,2}/, \"\");\n\n      if (!SUPPORTED_FLAGS.includes(name)) {\n        continue;\n      }\n\n      let value: string | boolean;\n      const nextEntry = parseEntries[i + 1];\n      const hasValue = !BOOLEAN_FLAGS.includes(name);\n      // Check if nextEntry looks like a flag:\n      // - Single dash followed by a letter: -X, -H, -d\n      // - Double dash followed by a letter: --data-raw, --header\n      // This prevents mistaking data that starts with dashes (like multipart boundaries ------) as flags\n      const nextEntryIsFlag =\n        typeof nextEntry === \"string\" &&\n        (nextEntry.match(/^-[a-zA-Z]/) || nextEntry.match(/^--[a-zA-Z]/));\n      if (isSingleDash && name.length > 1) {\n        // Handle squished arguments like -XPOST\n        value = name.slice(1);\n        name = name.slice(0, 1);\n      } else if (typeof nextEntry === \"string\" && hasValue && !nextEntryIsFlag) {\n        // Next arg is not a flag, so assign it as the value\n        value = nextEntry;\n        i++; // Skip next one\n      } else {\n        value = true;\n      }\n\n      flagsByName[name] = flagsByName[name] || [];\n      flagsByName[name]?.push(value);\n    } else if (parseEntry) {\n      singletons.push(parseEntry);\n    }\n  }\n\n  // ~~~~~~~~~~~~~~~~~ //\n  // Build the request //\n  // ~~~~~~~~~~~~~~~~~ //\n\n  const urlArg = getPairValue(flagsByName, (singletons[0] as string) || \"\", [\"url\"]);\n  const [baseUrl, search] = splitOnce(urlArg, \"?\");\n  const urlParameters: HttpUrlParameter[] =\n    search?.split(\"&\").map((p) => {\n      const v = splitOnce(p, \"=\");\n      return {\n        name: decodeURIComponent(v[0] ?? \"\"),\n        value: decodeURIComponent(v[1] ?? \"\"),\n        enabled: true,\n      };\n    }) ?? [];\n\n  const url = baseUrl ?? urlArg;\n\n  // Query params\n  for (const p of flagsByName[\"url-query\"] ?? []) {\n    if (typeof p !== \"string\") {\n      continue;\n    }\n    const [name, value] = p.split(\"=\");\n    urlParameters.push({\n      name: name ?? \"\",\n      value: value ?? \"\",\n      enabled: true,\n    });\n  }\n\n  // Authentication\n  const [username, password] = getPairValue(flagsByName, \"\", [\"u\", \"user\"]).split(/:(.*)$/);\n\n  const isDigest = getPairValue(flagsByName, false, [\"digest\"]);\n  const authenticationType = username ? (isDigest ? \"digest\" : \"basic\") : null;\n  const authentication = username\n    ? {\n        username: username.trim(),\n        password: (password ?? \"\").trim(),\n      }\n    : {};\n\n  // Headers\n  const headers = [\n    ...((flagsByName.header as string[] | undefined) || []),\n    ...((flagsByName.H as string[] | undefined) || []),\n  ].map((header) => {\n    const [name, value] = header.split(/:(.*)$/);\n    // remove final colon from header name if present\n    if (!value) {\n      return {\n        name: (name ?? \"\").trim().replace(/;$/, \"\"),\n        value: \"\",\n        enabled: true,\n      };\n    }\n    return {\n      name: (name ?? \"\").trim(),\n      value: value.trim(),\n      enabled: true,\n    };\n  });\n\n  // Cookies\n  const cookieHeaderValue = [\n    ...((flagsByName.cookie as string[] | undefined) || []),\n    ...((flagsByName.b as string[] | undefined) || []),\n  ]\n    .map((str) => {\n      const name = str.split(\"=\", 1)[0];\n      const value = str.replace(`${name}=`, \"\");\n      return `${name}=${value}`;\n    })\n    .join(\"; \");\n\n  // Convert cookie value to header\n  const existingCookieHeader = headers.find((header) => header.name.toLowerCase() === \"cookie\");\n\n  if (cookieHeaderValue && existingCookieHeader) {\n    // Has existing cookie header, so let's update it\n    existingCookieHeader.value += `; ${cookieHeaderValue}`;\n  } else if (cookieHeaderValue) {\n    // No existing cookie header, so let's make a new one\n    headers.push({\n      name: \"Cookie\",\n      value: cookieHeaderValue,\n      enabled: true,\n    });\n  }\n\n  // Body (Text or Blob)\n  const contentTypeHeader = headers.find((header) => header.name.toLowerCase() === \"content-type\");\n  const mimeType = contentTypeHeader ? contentTypeHeader.value.split(\";\")[0]?.trim() : null;\n\n  // Extract boundary from Content-Type header for multipart parsing\n  const boundaryMatch = contentTypeHeader?.value.match(/boundary=([^\\s;]+)/i);\n  const boundary = boundaryMatch?.[1];\n\n  // Get raw data from --data-raw flags (before splitting by &)\n  const rawDataValues = [\n    ...((flagsByName[\"data-raw\"] as string[] | undefined) || []),\n    ...((flagsByName.d as string[] | undefined) || []),\n    ...((flagsByName.data as string[] | undefined) || []),\n    ...((flagsByName[\"data-binary\"] as string[] | undefined) || []),\n    ...((flagsByName[\"data-ascii\"] as string[] | undefined) || []),\n  ];\n\n  // Check if this is multipart form data in --data-raw (Chrome DevTools format)\n  let multipartFormDataFromRaw:\n    | { name: string; value?: string; file?: string; enabled: boolean }[]\n    | null = null;\n  if (mimeType === \"multipart/form-data\" && boundary && rawDataValues.length > 0) {\n    const rawBody = rawDataValues.join(\"\");\n    multipartFormDataFromRaw = parseMultipartFormData(rawBody, boundary);\n  }\n\n  const dataParameters = pairsToDataParameters(flagsByName);\n\n  // Body (Multipart Form Data from -F flags)\n  const formDataParams = [\n    ...((flagsByName.form as string[] | undefined) || []),\n    ...((flagsByName.F as string[] | undefined) || []),\n  ].map((str) => {\n    const parts = str.split(\"=\");\n    const name = parts[0] ?? \"\";\n    const value = parts[1] ?? \"\";\n    const item: { name: string; value?: string; file?: string; enabled: boolean } = {\n      name,\n      enabled: true,\n    };\n\n    if (value.indexOf(\"@\") === 0) {\n      item.file = value.slice(1);\n    } else {\n      item.value = value;\n    }\n\n    return item;\n  });\n\n  // Body\n  let body = {};\n  let bodyType: string | null = null;\n  const bodyAsGET = getPairValue(flagsByName, false, [\"G\", \"get\"]);\n\n  if (multipartFormDataFromRaw) {\n    // Handle multipart form data parsed from --data-raw (Chrome DevTools format)\n    bodyType = \"multipart/form-data\";\n    body = {\n      form: multipartFormDataFromRaw,\n    };\n  } else if (dataParameters.length > 0 && bodyAsGET) {\n    urlParameters.push(...dataParameters);\n  } else if (\n    dataParameters.length > 0 &&\n    (mimeType == null || mimeType === \"application/x-www-form-urlencoded\")\n  ) {\n    bodyType = mimeType ?? \"application/x-www-form-urlencoded\";\n    body = {\n      form: dataParameters.map((parameter) => ({\n        ...parameter,\n        name: decodeURIComponent(parameter.name || \"\"),\n        value: decodeURIComponent(parameter.value || \"\"),\n      })),\n    };\n    headers.push({\n      name: \"Content-Type\",\n      value: \"application/x-www-form-urlencoded\",\n      enabled: true,\n    });\n  } else if (dataParameters.length > 0) {\n    bodyType =\n      mimeType === \"application/json\" || mimeType === \"text/xml\" || mimeType === \"text/plain\"\n        ? mimeType\n        : \"other\";\n    body = {\n      text: dataParameters\n        .map(({ name, value }) => (name && value ? `${name}=${value}` : name || value))\n        .join(\"&\"),\n    };\n  } else if (formDataParams.length) {\n    bodyType = mimeType ?? \"multipart/form-data\";\n    body = {\n      form: formDataParams,\n    };\n    if (mimeType == null) {\n      headers.push({\n        name: \"Content-Type\",\n        value: \"multipart/form-data\",\n        enabled: true,\n      });\n    }\n  }\n\n  // Method\n  let method = getPairValue(flagsByName, \"\", [\"X\", \"request\"]).toUpperCase();\n\n  if (method === \"\" && body) {\n    method = \"text\" in body || \"form\" in body ? \"POST\" : \"GET\";\n  }\n\n  const request: ExportResources[\"httpRequests\"][0] = {\n    id: generateId(\"http_request\"),\n    model: \"http_request\",\n    workspaceId,\n    name: \"\",\n    urlParameters,\n    url,\n    method,\n    headers,\n    authentication,\n    authenticationType,\n    body,\n    bodyType,\n    folderId: null,\n    sortPriority: 0,\n  };\n\n  return request;\n}\n\ninterface DataParameter {\n  name: string;\n  value: string;\n  contentType?: string;\n  filePath?: string;\n  enabled?: boolean;\n}\n\nfunction pairsToDataParameters(keyedPairs: FlagsByName): DataParameter[] {\n  const dataParameters: DataParameter[] = [];\n\n  for (const flagName of DATA_FLAGS) {\n    const pairs = keyedPairs[flagName];\n\n    if (!pairs || pairs.length === 0) {\n      continue;\n    }\n\n    for (const p of pairs) {\n      if (typeof p !== \"string\") continue;\n      const params = p.split(\"&\");\n      for (const param of params) {\n        const [name, value] = splitOnce(param, \"=\");\n        if (param.startsWith(\"@\")) {\n          // Yaak doesn't support files in url-encoded data, so\n          dataParameters.push({\n            name: name ?? \"\",\n            value: \"\",\n            filePath: param.slice(1),\n            enabled: true,\n          });\n        } else {\n          dataParameters.push({\n            name: name ?? \"\",\n            value: flagName === \"data-urlencode\" ? encodeURIComponent(value ?? \"\") : (value ?? \"\"),\n            enabled: true,\n          });\n        }\n      }\n    }\n  }\n\n  return dataParameters;\n}\n\nconst getPairValue = <T extends string | boolean>(\n  pairsByName: FlagsByName,\n  defaultValue: T,\n  names: string[],\n) => {\n  for (const name of names) {\n    if (pairsByName[name]?.length) {\n      return pairsByName[name]?.[0] as T;\n    }\n  }\n\n  return defaultValue;\n};\n\nfunction splitOnce(str: string, sep: string): string[] {\n  const index = str.indexOf(sep);\n  if (index > -1) {\n    return [str.slice(0, index), str.slice(index + 1)];\n  }\n  return [str];\n}\n\n/**\n * Parses multipart form data from a raw body string\n * Used when Chrome DevTools exports a cURL with --data-raw containing multipart data\n */\nfunction parseMultipartFormData(\n  rawBody: string,\n  boundary: string,\n): { name: string; value?: string; file?: string; enabled: boolean }[] | null {\n  const results: { name: string; value?: string; file?: string; enabled: boolean }[] = [];\n\n  // The boundary in the body typically has -- prefix\n  const boundaryMarker = `--${boundary}`;\n  const parts = rawBody.split(boundaryMarker);\n\n  for (const part of parts) {\n    // Skip empty parts and the closing boundary marker\n    if (!part || part.trim() === \"--\" || part.trim() === \"--\\r\\n\") {\n      continue;\n    }\n\n    // Each part has headers and content separated by \\r\\n\\r\\n\n    const headerContentSplit = part.indexOf(\"\\r\\n\\r\\n\");\n    if (headerContentSplit === -1) {\n      continue;\n    }\n\n    const headerSection = part.slice(0, headerContentSplit);\n    let content = part.slice(headerContentSplit + 4); // Skip \\r\\n\\r\\n\n\n    // Remove trailing \\r\\n from content\n    if (content.endsWith(\"\\r\\n\")) {\n      content = content.slice(0, -2);\n    }\n\n    // Parse Content-Disposition header to get name and filename\n    const contentDispositionMatch = headerSection.match(\n      /Content-Disposition:\\s*form-data;\\s*name=\"([^\"]+)\"(?:;\\s*filename=\"([^\"]+)\")?/i,\n    );\n\n    if (!contentDispositionMatch) {\n      continue;\n    }\n\n    const name = contentDispositionMatch[1] ?? \"\";\n    const filename = contentDispositionMatch[2];\n\n    const item: { name: string; value?: string; file?: string; enabled: boolean } = {\n      name,\n      enabled: true,\n    };\n\n    if (filename) {\n      // This is a file upload field\n      item.file = filename;\n    } else {\n      // This is a regular text field\n      item.value = content;\n    }\n\n    results.push(item);\n  }\n\n  return results.length > 0 ? results : null;\n}\n\nconst idCount: Partial<Record<string, number>> = {};\n\nfunction generateId(model: string): string {\n  idCount[model] = (idCount[model] ?? -1) + 1;\n  return `GENERATE_ID::${model.toUpperCase()}_${idCount[model]}`;\n}\n"
  },
  {
    "path": "plugins/importer-curl/tests/index.test.ts",
    "content": "import type { HttpRequest, Workspace } from \"@yaakapp/api\";\nimport { describe, expect, test } from \"vite-plus/test\";\nimport { convertCurl } from \"../src\";\n\ndescribe(\"importer-curl\", () => {\n  test(\"Imports basic GET\", () => {\n    expect(convertCurl(\"curl https://yaak.app\")).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Explicit URL\", () => {\n    expect(convertCurl(\"curl --url https://yaak.app\")).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Missing URL\", () => {\n    expect(convertCurl(\"curl -X POST\")).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            method: \"POST\",\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"URL between\", () => {\n    expect(convertCurl(\"curl -v https://yaak.app -X POST\")).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            method: \"POST\",\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Random flags\", () => {\n    expect(convertCurl(\"curl --random -Z -Y -S --foo https://yaak.app\")).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports --request method\", () => {\n    expect(convertCurl(\"curl --request POST https://yaak.app\")).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            method: \"POST\",\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports -XPOST method\", () => {\n    expect(convertCurl(\"curl -XPOST --request POST https://yaak.app\")).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            method: \"POST\",\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports multiple requests\", () => {\n    expect(\n      convertCurl('curl \\\\\\n  https://yaak.app\\necho \"foo\"\\ncurl example.com;curl foo.com'),\n    ).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({ url: \"https://yaak.app\" }),\n          baseRequest({ url: \"example.com\" }),\n          baseRequest({ url: \"foo.com\" }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports with Windows CRLF line endings\", () => {\n    expect(convertCurl(\"curl \\\\\\r\\n  -X POST \\\\\\r\\n  https://yaak.app\")).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [baseRequest({ url: \"https://yaak.app\", method: \"POST\" })],\n      },\n    });\n  });\n\n  test(\"Throws on malformed quotes\", () => {\n    expect(() => convertCurl('curl -X POST -F \"a=aaa\" -F b=bbb\" https://yaak.app')).toThrow();\n  });\n\n  test(\"Imports form data\", () => {\n    expect(convertCurl('curl -X POST -F \"a=aaa\" -F b=bbb -F f=@filepath https://yaak.app')).toEqual(\n      {\n        resources: {\n          workspaces: [baseWorkspace()],\n          httpRequests: [\n            baseRequest({\n              method: \"POST\",\n              url: \"https://yaak.app\",\n              headers: [\n                {\n                  name: \"Content-Type\",\n                  value: \"multipart/form-data\",\n                  enabled: true,\n                },\n              ],\n              bodyType: \"multipart/form-data\",\n              body: {\n                form: [\n                  { enabled: true, name: \"a\", value: \"aaa\" },\n                  { enabled: true, name: \"b\", value: \"bbb\" },\n                  { enabled: true, name: \"f\", file: \"filepath\" },\n                ],\n              },\n            }),\n          ],\n        },\n      },\n    );\n  });\n\n  test(\"Imports data params as form url-encoded\", () => {\n    expect(convertCurl(\"curl -d a -d b -d c=ccc https://yaak.app\")).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            method: \"POST\",\n            url: \"https://yaak.app\",\n            bodyType: \"application/x-www-form-urlencoded\",\n            headers: [\n              {\n                name: \"Content-Type\",\n                value: \"application/x-www-form-urlencoded\",\n                enabled: true,\n              },\n            ],\n            body: {\n              form: [\n                { name: \"a\", value: \"\", enabled: true },\n                { name: \"b\", value: \"\", enabled: true },\n                { name: \"c\", value: \"ccc\", enabled: true },\n              ],\n            },\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports combined data params as form url-encoded\", () => {\n    expect(convertCurl(`curl -d 'a=aaa&b=bbb&c' https://yaak.app`)).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            method: \"POST\",\n            url: \"https://yaak.app\",\n            bodyType: \"application/x-www-form-urlencoded\",\n            headers: [\n              {\n                name: \"Content-Type\",\n                value: \"application/x-www-form-urlencoded\",\n                enabled: true,\n              },\n            ],\n            body: {\n              form: [\n                { name: \"a\", value: \"aaa\", enabled: true },\n                { name: \"b\", value: \"bbb\", enabled: true },\n                { name: \"c\", value: \"\", enabled: true },\n              ],\n            },\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports data params as text\", () => {\n    expect(\n      convertCurl(\"curl -H Content-Type:text/plain -d a -d b -d c=ccc https://yaak.app\"),\n    ).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            method: \"POST\",\n            url: \"https://yaak.app\",\n            headers: [{ name: \"Content-Type\", value: \"text/plain\", enabled: true }],\n            bodyType: \"text/plain\",\n            body: { text: \"a&b&c=ccc\" },\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports post data into URL\", () => {\n    expect(convertCurl(\"curl -G https://api.stripe.com/v1/payment_links -d limit=3\")).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            method: \"GET\",\n            url: \"https://api.stripe.com/v1/payment_links\",\n            urlParameters: [\n              {\n                enabled: true,\n                name: \"limit\",\n                value: \"3\",\n              },\n            ],\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports multi-line JSON\", () => {\n    expect(\n      convertCurl(\n        `curl -H Content-Type:application/json -d $'{\\n  \"foo\":\"bar\"\\n}' https://yaak.app`,\n      ),\n    ).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            method: \"POST\",\n            url: \"https://yaak.app\",\n            headers: [{ name: \"Content-Type\", value: \"application/json\", enabled: true }],\n            bodyType: \"application/json\",\n            body: { text: '{\\n  \"foo\":\"bar\"\\n}' },\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports multiple headers\", () => {\n    expect(\n      convertCurl(\"curl -H Foo:bar --header Name -H AAA:bbb -H :ccc https://yaak.app\"),\n    ).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            headers: [\n              { name: \"Name\", value: \"\", enabled: true },\n              { name: \"Foo\", value: \"bar\", enabled: true },\n              { name: \"AAA\", value: \"bbb\", enabled: true },\n              { name: \"\", value: \"ccc\", enabled: true },\n            ],\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports basic auth\", () => {\n    expect(convertCurl(\"curl --user user:pass https://yaak.app\")).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            authenticationType: \"basic\",\n            authentication: {\n              username: \"user\",\n              password: \"pass\",\n            },\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports digest auth\", () => {\n    expect(convertCurl(\"curl --digest --user user:pass https://yaak.app\")).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            authenticationType: \"digest\",\n            authentication: {\n              username: \"user\",\n              password: \"pass\",\n            },\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports cookie as header\", () => {\n    expect(convertCurl('curl --cookie \"foo=bar\" https://yaak.app')).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            headers: [{ name: \"Cookie\", value: \"foo=bar\", enabled: true }],\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports query params\", () => {\n    expect(convertCurl('curl \"https://yaak.app\" --url-query foo=bar --url-query baz=qux')).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            urlParameters: [\n              { name: \"foo\", value: \"bar\", enabled: true },\n              { name: \"baz\", value: \"qux\", enabled: true },\n            ],\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports query params from the URL\", () => {\n    expect(convertCurl('curl \"https://yaak.app?foo=bar&baz=a%20a\"')).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            urlParameters: [\n              { name: \"foo\", value: \"bar\", enabled: true },\n              { name: \"baz\", value: \"a a\", enabled: true },\n            ],\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports weird body\", () => {\n    expect(convertCurl(`curl 'https://yaak.app' -X POST --data-raw 'foo=bar=baz'`)).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            method: \"POST\",\n            bodyType: \"application/x-www-form-urlencoded\",\n            body: {\n              form: [{ name: \"foo\", value: \"bar=baz\", enabled: true }],\n            },\n            headers: [\n              {\n                enabled: true,\n                name: \"Content-Type\",\n                value: \"application/x-www-form-urlencoded\",\n              },\n            ],\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports data with Unicode escape sequences\", () => {\n    expect(\n      convertCurl(\n        `curl 'https://yaak.app' -H 'Content-Type: application/json' --data-raw $'{\"query\":\"SearchQueryInput\\\\u0021\"}' -X POST`,\n      ),\n    ).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            method: \"POST\",\n            headers: [{ name: \"Content-Type\", value: \"application/json\", enabled: true }],\n            bodyType: \"application/json\",\n            body: { text: '{\"query\":\"SearchQueryInput!\"}' },\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports data with multiple escape sequences\", () => {\n    expect(\n      convertCurl(\n        `curl 'https://yaak.app' --data-raw $'Line1\\\\nLine2\\\\tTab\\\\u0021Exclamation' -X POST`,\n      ),\n    ).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            method: \"POST\",\n            bodyType: \"application/x-www-form-urlencoded\",\n            body: {\n              form: [{ name: \"Line1\\nLine2\\tTab!Exclamation\", value: \"\", enabled: true }],\n            },\n            headers: [\n              {\n                enabled: true,\n                name: \"Content-Type\",\n                value: \"application/x-www-form-urlencoded\",\n              },\n            ],\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports multipart form data from --data-raw (Chrome DevTools format)\", () => {\n    // This is the format Chrome DevTools uses when copying a multipart form submission as cURL\n    const curlCommand = `curl 'http://localhost:8080/system' \\\n  -H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryHwsXKi4rKA6P5VBd' \\\n  --data-raw $'------WebKitFormBoundaryHwsXKi4rKA6P5VBd\\r\\nContent-Disposition: form-data; name=\"username\"\\r\\n\\r\\njsgj\\r\\n------WebKitFormBoundaryHwsXKi4rKA6P5VBd\\r\\nContent-Disposition: form-data; name=\"password\"\\r\\n\\r\\n654321\\r\\n------WebKitFormBoundaryHwsXKi4rKA6P5VBd\\r\\nContent-Disposition: form-data; name=\"captcha\"; filename=\"test.xlsx\"\\r\\nContent-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\\r\\n\\r\\n\\r\\n------WebKitFormBoundaryHwsXKi4rKA6P5VBd--\\r\\n'`;\n\n    expect(convertCurl(curlCommand)).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"http://localhost:8080/system\",\n            method: \"POST\",\n            headers: [\n              {\n                name: \"Content-Type\",\n                value: \"multipart/form-data; boundary=----WebKitFormBoundaryHwsXKi4rKA6P5VBd\",\n                enabled: true,\n              },\n            ],\n            bodyType: \"multipart/form-data\",\n            body: {\n              form: [\n                { name: \"username\", value: \"jsgj\", enabled: true },\n                { name: \"password\", value: \"654321\", enabled: true },\n                { name: \"captcha\", file: \"test.xlsx\", enabled: true },\n              ],\n            },\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports JSON body with newlines in $quotes\", () => {\n    expect(\n      convertCurl(\n        `curl 'https://yaak.app' -H 'Content-Type: application/json' --data-raw $'{\\\\n  \"foo\": \"bar\",\\\\n  \"baz\": \"qux\"\\\\n}' -X POST`,\n      ),\n    ).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            method: \"POST\",\n            headers: [{ name: \"Content-Type\", value: \"application/json\", enabled: true }],\n            bodyType: \"application/json\",\n            body: { text: '{\\n  \"foo\": \"bar\",\\n  \"baz\": \"qux\"\\n}' },\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Handles double-quoted string ending with even backslashes before semicolon\", () => {\n    // \"C:\\\\\" has two backslashes which escape each other, so the closing \" is real.\n    // The ; after should split into a second command.\n    expect(convertCurl('curl -d \"C:\\\\\\\\\" https://yaak.app;curl https://example.com')).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            method: \"POST\",\n            bodyType: \"application/x-www-form-urlencoded\",\n            body: {\n              form: [{ name: \"C:\\\\\", value: \"\", enabled: true }],\n            },\n            headers: [\n              {\n                name: \"Content-Type\",\n                value: \"application/x-www-form-urlencoded\",\n                enabled: true,\n              },\n            ],\n          }),\n          baseRequest({ url: \"https://example.com\" }),\n        ],\n      },\n    });\n  });\n\n  test(\"Handles $quoted string ending with a literal backslash before semicolon\", () => {\n    // $'C:\\\\\\\\' has two backslashes which become one literal backslash.\n    // The closing ' must not be misinterpreted as escaped.\n    // The ; after should split into a second command.\n    expect(convertCurl(\"curl -d $'C:\\\\\\\\' https://yaak.app;curl https://example.com\")).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            method: \"POST\",\n            bodyType: \"application/x-www-form-urlencoded\",\n            body: {\n              form: [{ name: \"C:\\\\\", value: \"\", enabled: true }],\n            },\n            headers: [\n              {\n                name: \"Content-Type\",\n                value: \"application/x-www-form-urlencoded\",\n                enabled: true,\n              },\n            ],\n          }),\n          baseRequest({ url: \"https://example.com\" }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports $quoted header with escaped single quotes\", () => {\n    expect(convertCurl(`curl https://yaak.app -H $'X-Custom: it\\\\'s a test'`)).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            headers: [{ name: \"X-Custom\", value: \"it's a test\", enabled: true }],\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Does not split on escaped semicolon outside quotes\", () => {\n    // In shell, \\; is a literal semicolon and should not split commands.\n    // This should be treated as a single curl command with the URL \"https://yaak.app?a=1;b=2\"\n    expect(convertCurl(\"curl https://yaak.app?a=1\\\\;b=2\")).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"https://yaak.app\",\n            urlParameters: [{ name: \"a\", value: \"1;b=2\", enabled: true }],\n          }),\n        ],\n      },\n    });\n  });\n\n  test(\"Imports multipart form data with text-only fields from --data-raw\", () => {\n    const curlCommand = `curl 'http://example.com/api' \\\n  -H 'Content-Type: multipart/form-data; boundary=----FormBoundary123' \\\n  --data-raw $'------FormBoundary123\\r\\nContent-Disposition: form-data; name=\"field1\"\\r\\n\\r\\nvalue1\\r\\n------FormBoundary123\\r\\nContent-Disposition: form-data; name=\"field2\"\\r\\n\\r\\nvalue2\\r\\n------FormBoundary123--\\r\\n'`;\n\n    expect(convertCurl(curlCommand)).toEqual({\n      resources: {\n        workspaces: [baseWorkspace()],\n        httpRequests: [\n          baseRequest({\n            url: \"http://example.com/api\",\n            method: \"POST\",\n            headers: [\n              {\n                name: \"Content-Type\",\n                value: \"multipart/form-data; boundary=----FormBoundary123\",\n                enabled: true,\n              },\n            ],\n            bodyType: \"multipart/form-data\",\n            body: {\n              form: [\n                { name: \"field1\", value: \"value1\", enabled: true },\n                { name: \"field2\", value: \"value2\", enabled: true },\n              ],\n            },\n          }),\n        ],\n      },\n    });\n  });\n});\n\nconst idCount: Partial<Record<string, number>> = {};\n\nfunction baseRequest(mergeWith: Partial<HttpRequest>) {\n  idCount.http_request = (idCount.http_request ?? -1) + 1;\n  return {\n    id: `GENERATE_ID::HTTP_REQUEST_${idCount.http_request}`,\n    model: \"http_request\",\n    authentication: {},\n    authenticationType: null,\n    body: {},\n    bodyType: null,\n    folderId: null,\n    headers: [],\n    method: \"GET\",\n    name: \"\",\n    sortPriority: 0,\n    url: \"\",\n    urlParameters: [],\n    workspaceId: `GENERATE_ID::WORKSPACE_${idCount.workspace}`,\n    ...mergeWith,\n  };\n}\n\nfunction baseWorkspace(mergeWith: Partial<Workspace> = {}) {\n  idCount.workspace = (idCount.workspace ?? -1) + 1;\n  return {\n    id: `GENERATE_ID::WORKSPACE_${idCount.workspace}`,\n    model: \"workspace\",\n    name: \"Curl Import\",\n    ...mergeWith,\n  };\n}\n"
  },
  {
    "path": "plugins/importer-curl/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/importer-insomnia/package.json",
    "content": "{\n  \"name\": \"@yaak/importer-insomnia\",\n  \"displayName\": \"Insomnia Importer\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Import data from Insomnia\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  },\n  \"dependencies\": {\n    \"yaml\": \"^2.4.2\"\n  }\n}\n"
  },
  {
    "path": "plugins/importer-insomnia/src/common.ts",
    "content": "export function isJSObject(obj: unknown) {\n  return Object.prototype.toString.call(obj) === \"[object Object]\";\n}\n\nexport function isJSString(obj: unknown) {\n  return Object.prototype.toString.call(obj) === \"[object String]\";\n}\n\nexport function convertId(id: string): string {\n  if (id.startsWith(\"GENERATE_ID::\")) {\n    return id;\n  }\n  return `GENERATE_ID::${id}`;\n}\n\nexport function deleteUndefinedAttrs<T>(obj: T): T {\n  if (Array.isArray(obj) && obj != null) {\n    return obj.map(deleteUndefinedAttrs) as T;\n  }\n  if (typeof obj === \"object\" && obj != null) {\n    return Object.fromEntries(\n      Object.entries(obj)\n        .filter(([, v]) => v !== undefined)\n        .map(([k, v]) => [k, deleteUndefinedAttrs(v)]),\n    ) as T;\n  }\n  return obj;\n}\n\n/** Recursively render all nested object properties */\nexport function convertTemplateSyntax<T>(obj: T): T {\n  if (typeof obj === \"string\") {\n    // oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax\n    return obj.replaceAll(/{{\\s*(_\\.)?([^}]+)\\s*}}/g, \"${[$2]}\") as T;\n  }\n  if (Array.isArray(obj) && obj != null) {\n    return obj.map(convertTemplateSyntax) as T;\n  }\n  if (typeof obj === \"object\" && obj != null) {\n    return Object.fromEntries(\n      Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]),\n    ) as T;\n  }\n  return obj;\n}\n"
  },
  {
    "path": "plugins/importer-insomnia/src/index.ts",
    "content": "import type { Context, PluginDefinition } from \"@yaakapp/api\";\nimport YAML from \"yaml\";\nimport { deleteUndefinedAttrs, isJSObject } from \"./common\";\nimport { convertInsomniaV4 } from \"./v4\";\nimport { convertInsomniaV5 } from \"./v5\";\n\nexport const plugin: PluginDefinition = {\n  importer: {\n    name: \"Insomnia\",\n    description: \"Import Insomnia workspaces\",\n    async onImport(_ctx: Context, args: { text: string }) {\n      return convertInsomnia(args.text);\n    },\n  },\n};\n\nexport function convertInsomnia(contents: string) {\n  let parsed: unknown;\n\n  try {\n    parsed = JSON.parse(contents);\n  } catch {\n    // Fall through\n  }\n\n  try {\n    parsed = parsed ?? YAML.parse(contents);\n  } catch {\n    // Fall through\n  }\n\n  if (!isJSObject(parsed)) return null;\n\n  const result = convertInsomniaV5(parsed) ?? convertInsomniaV4(parsed);\n\n  return deleteUndefinedAttrs(result);\n}\n"
  },
  {
    "path": "plugins/importer-insomnia/src/v4.ts",
    "content": "/* oxlint-disable no-explicit-any */\nimport type { PartialImportResources } from \"@yaakapp/api\";\nimport { convertId, convertTemplateSyntax, isJSObject } from \"./common\";\n\nexport function convertInsomniaV4(parsed: any) {\n  if (!Array.isArray(parsed.resources)) return null;\n\n  const resources: PartialImportResources = {\n    environments: [],\n    folders: [],\n    grpcRequests: [],\n    httpRequests: [],\n    websocketRequests: [],\n    workspaces: [],\n  };\n\n  // Import workspaces\n  const workspacesToImport = parsed.resources.filter(\n    (r: any) => isJSObject(r) && r._type === \"workspace\",\n  );\n  for (const w of workspacesToImport) {\n    resources.workspaces.push({\n      id: convertId(w._id),\n      createdAt: w.created ? new Date(w.created).toISOString().replace(\"Z\", \"\") : undefined,\n      updatedAt: w.updated ? new Date(w.updated).toISOString().replace(\"Z\", \"\") : undefined,\n      model: \"workspace\",\n      name: w.name,\n      description: w.description || undefined,\n    });\n    const environmentsToImport = parsed.resources.filter(\n      (r: any) => isJSObject(r) && r._type === \"environment\",\n    );\n    resources.environments.push(\n      ...environmentsToImport.map((r: any) => importEnvironment(r, w._id)),\n    );\n\n    const nextFolder = (parentId: string) => {\n      const children = parsed.resources.filter((r: any) => r.parentId === parentId);\n      for (const child of children) {\n        if (!isJSObject(child)) continue;\n\n        if (child._type === \"request_group\") {\n          resources.folders.push(importFolder(child, w._id));\n          nextFolder(child._id);\n        } else if (child._type === \"request\") {\n          resources.httpRequests.push(importHttpRequest(child, w._id));\n        } else if (child._type === \"grpc_request\") {\n          resources.grpcRequests.push(importGrpcRequest(child, w._id));\n        }\n      }\n    };\n\n    // Import folders\n    nextFolder(w._id);\n  }\n\n  // Filter out any `null` values\n  resources.httpRequests = resources.httpRequests.filter(Boolean);\n  resources.grpcRequests = resources.grpcRequests.filter(Boolean);\n  resources.environments = resources.environments.filter(Boolean);\n  resources.workspaces = resources.workspaces.filter(Boolean);\n\n  return { resources: convertTemplateSyntax(resources) };\n}\n\nfunction importHttpRequest(r: any, workspaceId: string): PartialImportResources[\"httpRequests\"][0] {\n  let bodyType: string | null = null;\n  let body = {};\n  if (r.body.mimeType === \"application/octet-stream\") {\n    bodyType = \"binary\";\n    body = { filePath: r.body.fileName ?? \"\" };\n  } else if (r.body?.mimeType === \"application/x-www-form-urlencoded\") {\n    bodyType = \"application/x-www-form-urlencoded\";\n    body = {\n      form: (r.body.params ?? []).map((p: any) => ({\n        enabled: !p.disabled,\n        name: p.name ?? \"\",\n        value: p.value ?? \"\",\n      })),\n    };\n  } else if (r.body?.mimeType === \"multipart/form-data\") {\n    bodyType = \"multipart/form-data\";\n    body = {\n      form: (r.body.params ?? []).map((p: any) => ({\n        enabled: !p.disabled,\n        name: p.name ?? \"\",\n        value: p.value ?? \"\",\n        file: p.fileName ?? null,\n      })),\n    };\n  } else if (r.body?.mimeType === \"application/graphql\") {\n    bodyType = \"graphql\";\n    body = { text: r.body.text ?? \"\" };\n  } else if (r.body?.mimeType === \"application/json\") {\n    bodyType = \"application/json\";\n    body = { text: r.body.text ?? \"\" };\n  }\n\n  let authenticationType: string | null = null;\n  let authentication = {};\n  if (r.authentication.type === \"bearer\") {\n    authenticationType = \"bearer\";\n    authentication = {\n      token: r.authentication.token,\n    };\n  } else if (r.authentication.type === \"basic\") {\n    authenticationType = \"basic\";\n    authentication = {\n      username: r.authentication.username,\n      password: r.authentication.password,\n    };\n  }\n\n  return {\n    id: convertId(r.meta?.id ?? r._id),\n    createdAt: r.created ? new Date(r.created).toISOString().replace(\"Z\", \"\") : undefined,\n    updatedAt: r.modified ? new Date(r.modified).toISOString().replace(\"Z\", \"\") : undefined,\n    workspaceId: convertId(workspaceId),\n    folderId: r.parentId === workspaceId ? null : convertId(r.parentId),\n    model: \"http_request\",\n    sortPriority: r.metaSortKey,\n    name: r.name,\n    description: r.description || undefined,\n    url: r.url,\n    urlParameters: (r.parameters ?? []).map((p: any) => ({\n      enabled: !p.disabled,\n      name: p.name ?? \"\",\n      value: p.value ?? \"\",\n    })),\n    body,\n    bodyType,\n    authentication,\n    authenticationType,\n    method: r.method,\n    headers: (r.headers ?? [])\n      .map((h: any) => ({\n        enabled: !h.disabled,\n        name: h.name ?? \"\",\n        value: h.value ?? \"\",\n      }))\n      .filter(({ name, value }: any) => name !== \"\" || value !== \"\"),\n  };\n}\n\nfunction importGrpcRequest(r: any, workspaceId: string): PartialImportResources[\"grpcRequests\"][0] {\n  const parts = r.protoMethodName.split(\"/\").filter((p: any) => p !== \"\");\n  const service = parts[0] ?? null;\n  const method = parts[1] ?? null;\n\n  return {\n    id: convertId(r.meta?.id ?? r._id),\n    createdAt: r.created ? new Date(r.created).toISOString().replace(\"Z\", \"\") : undefined,\n    updatedAt: r.modified ? new Date(r.modified).toISOString().replace(\"Z\", \"\") : undefined,\n    workspaceId: convertId(workspaceId),\n    folderId: r.parentId === workspaceId ? null : convertId(r.parentId),\n    model: \"grpc_request\",\n    sortPriority: r.metaSortKey,\n    name: r.name,\n    description: r.description || undefined,\n    url: r.url,\n    service,\n    method,\n    message: r.body?.text ?? \"\",\n    metadata: (r.metadata ?? [])\n      .map((h: any) => ({\n        enabled: !h.disabled,\n        name: h.name ?? \"\",\n        value: h.value ?? \"\",\n      }))\n      .filter(({ name, value }: any) => name !== \"\" || value !== \"\"),\n  };\n}\n\nfunction importFolder(f: any, workspaceId: string): PartialImportResources[\"folders\"][0] {\n  return {\n    id: convertId(f._id),\n    createdAt: f.created ? new Date(f.created).toISOString().replace(\"Z\", \"\") : undefined,\n    updatedAt: f.modified ? new Date(f.modified).toISOString().replace(\"Z\", \"\") : undefined,\n    folderId: f.parentId === workspaceId ? null : convertId(f.parentId),\n    workspaceId: convertId(workspaceId),\n    description: f.description || undefined,\n    model: \"folder\",\n    name: f.name,\n  };\n}\n\nfunction importEnvironment(\n  e: any,\n  workspaceId: string,\n  isParentOg?: boolean,\n): PartialImportResources[\"environments\"][0] {\n  const isParent = isParentOg ?? e.parentId === workspaceId;\n  return {\n    id: convertId(e._id),\n    createdAt: e.created ? new Date(e.created).toISOString().replace(\"Z\", \"\") : undefined,\n    updatedAt: e.modified ? new Date(e.modified).toISOString().replace(\"Z\", \"\") : undefined,\n    workspaceId: convertId(workspaceId),\n    sortPriority: e.metaSortKey,\n    parentModel: isParent ? \"workspace\" : \"environment\",\n    parentId: null,\n    model: \"environment\",\n    name: e.name,\n    variables: Object.entries(e.data).map(([name, value]) => ({\n      enabled: true,\n      name,\n      value: String(value),\n    })),\n  };\n}\n"
  },
  {
    "path": "plugins/importer-insomnia/src/v5.ts",
    "content": "/* oxlint-disable no-explicit-any */\nimport type { PartialImportResources } from \"@yaakapp/api\";\nimport { convertId, convertTemplateSyntax, isJSObject } from \"./common\";\n\nexport function convertInsomniaV5(parsed: any) {\n  // Assert parsed is object\n  if (parsed == null || typeof parsed !== \"object\") {\n    return null;\n  }\n\n  if (!(\"collection\" in parsed) || !Array.isArray(parsed.collection)) {\n    return null;\n  }\n\n  const resources: PartialImportResources = {\n    environments: [],\n    folders: [],\n    grpcRequests: [],\n    httpRequests: [],\n    websocketRequests: [],\n    workspaces: [],\n  };\n\n  // Import workspaces\n  const meta = (\"meta\" in parsed ? parsed.meta : {}) as Record<string, any>;\n  resources.workspaces.push({\n    id: convertId(meta.id ?? \"collection\"),\n    createdAt: meta.created ? new Date(meta.created).toISOString().replace(\"Z\", \"\") : undefined,\n    updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace(\"Z\", \"\") : undefined,\n    model: \"workspace\",\n    name: parsed.name,\n    description: meta.description || undefined,\n    ...importHeaders(parsed),\n    ...importAuthentication(parsed),\n  });\n\n  // Import environments\n  resources.environments.push(\n    importEnvironment(parsed.environments, meta.id, true),\n    ...(parsed.environments.subEnvironments ?? []).map((r: any) => importEnvironment(r, meta.id)),\n  );\n\n  // Import folders\n  const nextFolder = (children: any[], parentId: string) => {\n    for (const child of children ?? []) {\n      if (!isJSObject(child)) continue;\n\n      if (Array.isArray(child.children)) {\n        const { folder, environment } = importFolder(child, meta.id, parentId);\n        resources.folders.push(folder);\n        if (environment) resources.environments.push(environment);\n        nextFolder(child.children, child.meta.id);\n      } else if (child.method) {\n        resources.httpRequests.push(importHttpRequest(child, meta.id, parentId));\n      } else if (child.protoFileId) {\n        resources.grpcRequests.push(importGrpcRequest(child, meta.id, parentId));\n      } else if (child.url) {\n        resources.websocketRequests.push(importWebsocketRequest(child, meta.id, parentId));\n      }\n    }\n  };\n\n  // Import folders\n  nextFolder(parsed.collection ?? [], meta.id);\n\n  // Filter out any `null` values\n  resources.httpRequests = resources.httpRequests.filter(Boolean);\n  resources.grpcRequests = resources.grpcRequests.filter(Boolean);\n  resources.environments = resources.environments.filter(Boolean);\n  resources.workspaces = resources.workspaces.filter(Boolean);\n\n  return { resources: convertTemplateSyntax(resources) };\n}\n\nfunction importHttpRequest(\n  r: any,\n  workspaceId: string,\n  parentId: string,\n): PartialImportResources[\"httpRequests\"][0] {\n  const id = r.meta?.id ?? r._id;\n  const created = r.meta?.created ?? r.created;\n  const updated = r.meta?.modified ?? r.updated;\n  const sortKey = r.meta?.sortKey ?? r.sortKey;\n\n  let bodyType: string | null = null;\n  let body = {};\n  if (r.body?.mimeType === \"application/octet-stream\") {\n    bodyType = \"binary\";\n    body = { filePath: r.body.fileName ?? \"\" };\n  } else if (r.body?.mimeType === \"application/x-www-form-urlencoded\") {\n    bodyType = \"application/x-www-form-urlencoded\";\n    body = {\n      form: (r.body.params ?? []).map((p: any) => ({\n        enabled: !p.disabled,\n        name: p.name ?? \"\",\n        value: p.value ?? \"\",\n      })),\n    };\n  } else if (r.body?.mimeType === \"multipart/form-data\") {\n    bodyType = \"multipart/form-data\";\n    body = {\n      form: (r.body.params ?? []).map((p: any) => ({\n        enabled: !p.disabled,\n        name: p.name ?? \"\",\n        value: p.value ?? \"\",\n        file: p.fileName ?? null,\n      })),\n    };\n  } else if (r.body?.mimeType === \"application/graphql\") {\n    bodyType = \"graphql\";\n    body = { text: r.body.text ?? \"\" };\n  } else if (r.body?.mimeType === \"application/json\") {\n    bodyType = \"application/json\";\n    body = { text: r.body.text ?? \"\" };\n  }\n\n  return {\n    id: convertId(id),\n    workspaceId: convertId(workspaceId),\n    createdAt: created ? new Date(created).toISOString().replace(\"Z\", \"\") : undefined,\n    updatedAt: updated ? new Date(updated).toISOString().replace(\"Z\", \"\") : undefined,\n    folderId: parentId === workspaceId ? null : convertId(parentId),\n    sortPriority: sortKey,\n    model: \"http_request\",\n    name: r.name,\n    description: r.meta?.description || undefined,\n    url: r.url,\n    urlParameters: (r.parameters ?? []).map((p: any) => ({\n      enabled: !p.disabled,\n      name: p.name ?? \"\",\n      value: p.value ?? \"\",\n    })),\n    body,\n    bodyType,\n    method: r.method,\n    ...importHeaders(r),\n    ...importAuthentication(r),\n  };\n}\n\nfunction importGrpcRequest(\n  r: any,\n  workspaceId: string,\n  parentId: string,\n): PartialImportResources[\"grpcRequests\"][0] {\n  const id = r.meta?.id ?? r._id;\n  const created = r.meta?.created ?? r.created;\n  const updated = r.meta?.modified ?? r.updated;\n  const sortKey = r.meta?.sortKey ?? r.sortKey;\n\n  const parts = r.protoMethodName.split(\"/\").filter((p: any) => p !== \"\");\n  const service = parts[0] ?? null;\n  const method = parts[1] ?? null;\n\n  return {\n    model: \"grpc_request\",\n    id: convertId(id),\n    workspaceId: convertId(workspaceId),\n    createdAt: created ? new Date(created).toISOString().replace(\"Z\", \"\") : undefined,\n    updatedAt: updated ? new Date(updated).toISOString().replace(\"Z\", \"\") : undefined,\n    folderId: parentId === workspaceId ? null : convertId(parentId),\n    sortPriority: sortKey,\n    name: r.name,\n    description: r.description || undefined,\n    url: r.url,\n    service,\n    method,\n    message: r.body?.text ?? \"\",\n    metadata: (r.metadata ?? [])\n      .map((h: any) => ({\n        enabled: !h.disabled,\n        name: h.name ?? \"\",\n        value: h.value ?? \"\",\n      }))\n      .filter(({ name, value }: any) => name !== \"\" || value !== \"\"),\n  };\n}\n\nfunction importWebsocketRequest(\n  r: any,\n  workspaceId: string,\n  parentId: string,\n): PartialImportResources[\"websocketRequests\"][0] {\n  const id = r.meta?.id ?? r._id;\n  const created = r.meta?.created ?? r.created;\n  const updated = r.meta?.modified ?? r.updated;\n  const sortKey = r.meta?.sortKey ?? r.sortKey;\n\n  return {\n    model: \"websocket_request\",\n    id: convertId(id),\n    workspaceId: convertId(workspaceId),\n    createdAt: created ? new Date(created).toISOString().replace(\"Z\", \"\") : undefined,\n    updatedAt: updated ? new Date(updated).toISOString().replace(\"Z\", \"\") : undefined,\n    folderId: parentId === workspaceId ? null : convertId(parentId),\n    sortPriority: sortKey,\n    name: r.name,\n    description: r.description || undefined,\n    url: r.url,\n    message: r.body?.text ?? \"\",\n    ...importHeaders(r),\n    ...importAuthentication(r),\n  };\n}\n\nfunction importHeaders(obj: any) {\n  const headers = (obj.headers ?? [])\n    .map((h: any) => ({\n      enabled: !h.disabled,\n      name: h.name ?? \"\",\n      value: h.value ?? \"\",\n    }))\n    .filter(({ name, value }: any) => name !== \"\" || value !== \"\");\n  return { headers } as const;\n}\n\nfunction importAuthentication(obj: any) {\n  let authenticationType: string | null = null;\n  let authentication = {};\n  if (obj.authentication?.type === \"bearer\") {\n    authenticationType = \"bearer\";\n    authentication = {\n      token: obj.authentication.token,\n    };\n  } else if (obj.authentication?.type === \"basic\") {\n    authenticationType = \"basic\";\n    authentication = {\n      username: obj.authentication.username,\n      password: obj.authentication.password,\n    };\n  }\n\n  return { authenticationType, authentication } as const;\n}\n\nfunction importFolder(\n  f: any,\n  workspaceId: string,\n  parentId: string,\n): {\n  folder: PartialImportResources[\"folders\"][0];\n  environment: PartialImportResources[\"environments\"][0] | null;\n} {\n  const id = f.meta?.id ?? f._id;\n  const created = f.meta?.created ?? f.created;\n  const updated = f.meta?.modified ?? f.updated;\n  const sortKey = f.meta?.sortKey ?? f.sortKey;\n\n  let environment: PartialImportResources[\"environments\"][0] | null = null;\n  if (Object.keys(f.environment ?? {}).length > 0) {\n    environment = {\n      id: convertId(`${id}folder`),\n      createdAt: created ? new Date(created).toISOString().replace(\"Z\", \"\") : undefined,\n      updatedAt: updated ? new Date(updated).toISOString().replace(\"Z\", \"\") : undefined,\n      workspaceId: convertId(workspaceId),\n      public: true,\n      parentModel: \"folder\",\n      parentId: convertId(id),\n      model: \"environment\",\n      name: \"Folder Environment\",\n      variables: Object.entries(f.environment ?? {}).map(([name, value]) => ({\n        enabled: true,\n        name,\n        value: String(value),\n      })),\n    };\n  }\n\n  return {\n    folder: {\n      model: \"folder\",\n      id: convertId(id),\n      createdAt: created ? new Date(created).toISOString().replace(\"Z\", \"\") : undefined,\n      updatedAt: updated ? new Date(updated).toISOString().replace(\"Z\", \"\") : undefined,\n      folderId: parentId === workspaceId ? null : convertId(parentId),\n      sortPriority: sortKey,\n      workspaceId: convertId(workspaceId),\n      description: f.description || undefined,\n      name: f.name,\n      ...importAuthentication(f),\n      ...importHeaders(f),\n    },\n    environment,\n  };\n}\n\nfunction importEnvironment(\n  e: any,\n  workspaceId: string,\n  isParent?: boolean,\n): PartialImportResources[\"environments\"][0] {\n  const id = e.meta?.id ?? e._id;\n  const created = e.meta?.created ?? e.created;\n  const updated = e.meta?.modified ?? e.updated;\n  const sortKey = e.meta?.sortKey ?? e.sortKey;\n\n  return {\n    id: convertId(id),\n    createdAt: created ? new Date(created).toISOString().replace(\"Z\", \"\") : undefined,\n    updatedAt: updated ? new Date(updated).toISOString().replace(\"Z\", \"\") : undefined,\n    workspaceId: convertId(workspaceId),\n    public: !e.isPrivate,\n    sortPriority: sortKey,\n    parentModel: isParent ? \"workspace\" : \"environment\",\n    parentId: null,\n    model: \"environment\",\n    name: e.name,\n    variables: Object.entries(e.data ?? {}).map(([name, value]) => ({\n      enabled: true,\n      name,\n      value: String(value),\n    })),\n  };\n}\n"
  },
  {
    "path": "plugins/importer-insomnia/tests/fixtures/basic.input.json",
    "content": "{\n  \"_type\": \"export\",\n  \"__export_format\": 4,\n  \"__export_date\": \"2025-01-13T15:19:18.330Z\",\n  \"__export_source\": \"insomnia.desktop.app:v10.3.0\",\n  \"resources\": [\n    {\n      \"_id\": \"req_84cd9ae4bd034dd8bb730e856a665cbb\",\n      \"parentId\": \"fld_859d1df78261463480b6a3a1419517e3\",\n      \"modified\": 1736781473176,\n      \"created\": 1736781406672,\n      \"url\": \"{{ _.BASE_URL }}/foo/:id\",\n      \"name\": \"New Request\",\n      \"description\": \"My description of the request\",\n      \"method\": \"GET\",\n      \"body\": {\n        \"mimeType\": \"multipart/form-data\",\n        \"params\": [\n          {\n            \"id\": \"pair_7c86036ae8ef499dbbc0b43d0800c5a3\",\n            \"name\": \"form\",\n            \"value\": \"data\",\n            \"description\": \"\",\n            \"disabled\": false\n          }\n        ]\n      },\n      \"parameters\": [\n        {\n          \"id\": \"pair_b22f6ff611cd4250a6e405ca7b713d09\",\n          \"name\": \"query\",\n          \"value\": \"qqq\",\n          \"description\": \"\",\n          \"disabled\": false\n        }\n      ],\n      \"headers\": [\n        {\n          \"name\": \"Content-Type\",\n          \"value\": \"multipart/form-data\",\n          \"id\": \"pair_4af845963bd14256b98716617971eecd\"\n        },\n        {\n          \"name\": \"User-Agent\",\n          \"value\": \"insomnia/10.3.0\",\n          \"id\": \"pair_535ffd00ce48462cb1b7258832ade65a\"\n        },\n        {\n          \"id\": \"pair_ab4b870278e943cba6babf5a73e213e3\",\n          \"name\": \"X-Header\",\n          \"value\": \"xxxx\",\n          \"description\": \"\",\n          \"disabled\": false\n        }\n      ],\n      \"authentication\": {\n        \"type\": \"basic\",\n        \"useISO88591\": false,\n        \"disabled\": false,\n        \"username\": \"user\",\n        \"password\": \"pass\"\n      },\n      \"metaSortKey\": -1736781406672,\n      \"isPrivate\": false,\n      \"pathParameters\": [\n        {\n          \"name\": \"id\",\n          \"value\": \"iii\"\n        }\n      ],\n      \"settingStoreCookies\": true,\n      \"settingSendCookies\": true,\n      \"settingDisableRenderRequestBody\": false,\n      \"settingEncodeUrl\": true,\n      \"settingRebuildPath\": true,\n      \"settingFollowRedirects\": \"global\",\n      \"_type\": \"request\"\n    },\n    {\n      \"_id\": \"fld_859d1df78261463480b6a3a1419517e3\",\n      \"parentId\": \"wrk_d4d92f7c0ee947b89159243506687019\",\n      \"modified\": 1736781404718,\n      \"created\": 1736781404718,\n      \"name\": \"Top Level\",\n      \"description\": \"\",\n      \"environment\": {},\n      \"environmentPropertyOrder\": null,\n      \"metaSortKey\": -1736781404718,\n      \"environmentType\": \"kv\",\n      \"_type\": \"request_group\"\n    },\n    {\n      \"_id\": \"wrk_d4d92f7c0ee947b89159243506687019\",\n      \"parentId\": null,\n      \"modified\": 1736781343765,\n      \"created\": 1736781343765,\n      \"name\": \"Dummy\",\n      \"description\": \"\",\n      \"scope\": \"collection\",\n      \"_type\": \"workspace\"\n    },\n    {\n      \"_id\": \"env_16c0dec5b77c414ae0e419b8f10c3701300c5900\",\n      \"parentId\": \"wrk_d4d92f7c0ee947b89159243506687019\",\n      \"modified\": 1736781355209,\n      \"created\": 1736781343767,\n      \"name\": \"Base Environment\",\n      \"data\": {\n        \"BASE_VAR\": \"hello\"\n      },\n      \"dataPropertyOrder\": null,\n      \"color\": null,\n      \"isPrivate\": false,\n      \"metaSortKey\": 1736781343767,\n      \"environmentType\": \"kv\",\n      \"kvPairData\": [\n        {\n          \"id\": \"envPair_61c1be66d42241b5a28306d2cd92d3e3\",\n          \"name\": \"BASE_VAR\",\n          \"value\": \"hello\",\n          \"type\": \"str\",\n          \"enabled\": true\n        }\n      ],\n      \"_type\": \"environment\"\n    },\n    {\n      \"_id\": \"jar_16c0dec5b77c414ae0e419b8f10c3701300c5900\",\n      \"parentId\": \"wrk_d4d92f7c0ee947b89159243506687019\",\n      \"modified\": 1736781343768,\n      \"created\": 1736781343768,\n      \"name\": \"Default Jar\",\n      \"cookies\": [],\n      \"_type\": \"cookie_jar\"\n    },\n    {\n      \"_id\": \"env_799ae3d723ef44af91b4817e5d057e6d\",\n      \"parentId\": \"env_16c0dec5b77c414ae0e419b8f10c3701300c5900\",\n      \"modified\": 1736781394705,\n      \"created\": 1736781358515,\n      \"name\": \"Production\",\n      \"data\": {\n        \"BASE_URL\": \"https://api.yaak.app\"\n      },\n      \"dataPropertyOrder\": null,\n      \"color\": \"#f22c2c\",\n      \"isPrivate\": false,\n      \"metaSortKey\": 1736781358515,\n      \"environmentType\": \"kv\",\n      \"kvPairData\": [\n        {\n          \"id\": \"envPair_4d97b569b7e845ccbf488e1b26637cbc\",\n          \"name\": \"BASE_URL\",\n          \"value\": \"https://api.yaak.app\",\n          \"type\": \"str\",\n          \"enabled\": true\n        }\n      ],\n      \"_type\": \"environment\"\n    },\n    {\n      \"_id\": \"env_030fbfdbb274426ebd78e2e6518f8553\",\n      \"parentId\": \"env_16c0dec5b77c414ae0e419b8f10c3701300c5900\",\n      \"modified\": 1736781391078,\n      \"created\": 1736781374707,\n      \"name\": \"Staging\",\n      \"data\": {\n        \"BASE_URL\": \"https://api.staging.yaak.app\"\n      },\n      \"dataPropertyOrder\": null,\n      \"color\": \"#206fac\",\n      \"isPrivate\": false,\n      \"metaSortKey\": 1736781358565,\n      \"environmentType\": \"kv\",\n      \"kvPairData\": [\n        {\n          \"id\": \"envPair_4d97b569b7e845ccbf488e1b26637cbc\",\n          \"name\": \"BASE_URL\",\n          \"value\": \"https://api.staging.yaak.app\",\n          \"type\": \"str\",\n          \"enabled\": true\n        }\n      ],\n      \"_type\": \"environment\"\n    }\n  ]\n}\n"
  },
  {
    "path": "plugins/importer-insomnia/tests/fixtures/basic.output.json",
    "content": "{\n  \"resources\": {\n    \"environments\": [\n      {\n        \"createdAt\": \"2025-01-13T15:15:43.767\",\n        \"updatedAt\": \"2025-01-13T15:15:55.209\",\n        \"sortPriority\": 1736781343767,\n        \"parentId\": null,\n        \"parentModel\": \"workspace\",\n        \"id\": \"GENERATE_ID::env_16c0dec5b77c414ae0e419b8f10c3701300c5900\",\n        \"model\": \"environment\",\n        \"name\": \"Base Environment\",\n        \"variables\": [\n          {\n            \"enabled\": true,\n            \"name\": \"BASE_VAR\",\n            \"value\": \"hello\"\n          }\n        ],\n        \"workspaceId\": \"GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019\"\n      },\n      {\n        \"createdAt\": \"2025-01-13T15:15:58.515\",\n        \"updatedAt\": \"2025-01-13T15:16:34.705\",\n        \"sortPriority\": 1736781358515,\n        \"parentId\": null,\n        \"parentModel\": \"environment\",\n        \"id\": \"GENERATE_ID::env_799ae3d723ef44af91b4817e5d057e6d\",\n        \"model\": \"environment\",\n        \"name\": \"Production\",\n        \"variables\": [\n          {\n            \"enabled\": true,\n            \"name\": \"BASE_URL\",\n            \"value\": \"https://api.yaak.app\"\n          }\n        ],\n        \"workspaceId\": \"GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019\"\n      },\n      {\n        \"createdAt\": \"2025-01-13T15:16:14.707\",\n        \"updatedAt\": \"2025-01-13T15:16:31.078\",\n        \"sortPriority\": 1736781358565,\n        \"parentId\": null,\n        \"parentModel\": \"environment\",\n        \"id\": \"GENERATE_ID::env_030fbfdbb274426ebd78e2e6518f8553\",\n        \"model\": \"environment\",\n        \"name\": \"Staging\",\n        \"variables\": [\n          {\n            \"enabled\": true,\n            \"name\": \"BASE_URL\",\n            \"value\": \"https://api.staging.yaak.app\"\n          }\n        ],\n        \"workspaceId\": \"GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019\"\n      }\n    ],\n    \"folders\": [\n      {\n        \"createdAt\": \"2025-01-13T15:16:44.718\",\n        \"updatedAt\": \"2025-01-13T15:16:44.718\",\n        \"folderId\": null,\n        \"id\": \"GENERATE_ID::fld_859d1df78261463480b6a3a1419517e3\",\n        \"model\": \"folder\",\n        \"name\": \"Top Level\",\n        \"workspaceId\": \"GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019\"\n      }\n    ],\n    \"grpcRequests\": [],\n    \"httpRequests\": [\n      {\n        \"authentication\": {\n          \"password\": \"pass\",\n          \"username\": \"user\"\n        },\n        \"authenticationType\": \"basic\",\n        \"body\": {\n          \"form\": [\n            {\n              \"enabled\": true,\n              \"file\": null,\n              \"name\": \"form\",\n              \"value\": \"data\"\n            }\n          ]\n        },\n        \"bodyType\": \"multipart/form-data\",\n        \"createdAt\": \"2025-01-13T15:16:46.672\",\n        \"sortPriority\": -1736781406672,\n        \"updatedAt\": \"2025-01-13T15:17:53.176\",\n        \"description\": \"My description of the request\",\n        \"folderId\": \"GENERATE_ID::fld_859d1df78261463480b6a3a1419517e3\",\n        \"headers\": [\n          {\n            \"enabled\": true,\n            \"name\": \"Content-Type\",\n            \"value\": \"multipart/form-data\"\n          },\n          {\n            \"enabled\": true,\n            \"name\": \"User-Agent\",\n            \"value\": \"insomnia/10.3.0\"\n          },\n          {\n            \"enabled\": true,\n            \"name\": \"X-Header\",\n            \"value\": \"xxxx\"\n          }\n        ],\n        \"id\": \"GENERATE_ID::req_84cd9ae4bd034dd8bb730e856a665cbb\",\n        \"method\": \"GET\",\n        \"model\": \"http_request\",\n        \"name\": \"New Request\",\n        \"url\": \"${[BASE_URL ]}/foo/:id\",\n        \"urlParameters\": [\n          {\n            \"name\": \"query\",\n            \"value\": \"qqq\",\n            \"enabled\": true\n          }\n        ],\n        \"workspaceId\": \"GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019\"\n      }\n    ],\n    \"websocketRequests\": [],\n    \"workspaces\": [\n      {\n        \"createdAt\": \"2025-01-13T15:15:43.765\",\n        \"id\": \"GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019\",\n        \"model\": \"workspace\",\n        \"name\": \"Dummy\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "plugins/importer-insomnia/tests/fixtures/version-5-minimal.input.yaml",
    "content": "type: collection.insomnia.rest/5.0\nname: Debugging\nmeta:\n  id: wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c\n  created: 1747197924902\n  modified: 1747197924902\ncollection:\n  - name: My Folder\n    meta:\n      id: fld_296933ea4ea84783a775d199997e9be7\n      created: 1747414092298\n      modified: 1747414142427\n      sortKey: -1747414092298\n    children:\n      - url: https://httpbin.org/post\n        name: New Request\n        meta:\n          id: req_9a80320365ac4509ade406359dbc6a71\n          created: 1747197928502\n          modified: 1747414129313\n          isPrivate: false\n          sortKey: -1747414129276\n        method: GET\n        headers:\n          - name: User-Agent\n            value: insomnia/11.1.0\n            id: pair_6ae87d1620a9494f8e5b29cd9f92d087\n        settings:\n          renderRequestBody: true\n          encodeUrl: true\n          followRedirects: global\n          cookies:\n            send: true\n            store: true\n          rebuildPath: true\n    headers:\n      - id: pair_f2b330e3914f4c11b209318aef94325c\n        name: foo\n        value: bar\n        disabled: false\n    environment:\n      folder_env_var: testing\n  - name: New Request\n    meta:\n      id: req_e3f8cdbd58784a539dd4c1e127d73451\n      created: 1747414160497\n      modified: 1747414160497\n      isPrivate: false\n      sortKey: -1747414160498\n    method: GET\n    headers:\n      - name: User-Agent\n        value: insomnia/11.1.0\n    settings:\n      renderRequestBody: true\n      encodeUrl: true\n      followRedirects: global\n      cookies:\n        send: true\n        store: true\n      rebuildPath: true\ncookieJar:\n  name: Default Jar\n  meta:\n    id: jar_e46dc73e8ccda30ca132153e8f11183bd08119ce\n    created: 1747197924904\n    modified: 1747197924904\nenvironments:\n  name: Base Environment\n  meta:\n    id: env_e46dc73e8ccda30ca132153e8f11183bd08119ce\n    created: 1747197924903\n    modified: 1747197924903\n    isPrivate: false\n"
  },
  {
    "path": "plugins/importer-insomnia/tests/fixtures/version-5-minimal.output.json",
    "content": "{\n  \"resources\": {\n    \"environments\": [\n      {\n        \"createdAt\": \"2025-05-14T04:45:24.903\",\n        \"id\": \"GENERATE_ID::env_e46dc73e8ccda30ca132153e8f11183bd08119ce\",\n        \"model\": \"environment\",\n        \"name\": \"Base Environment\",\n        \"public\": true,\n        \"updatedAt\": \"2025-05-14T04:45:24.903\",\n        \"variables\": [],\n        \"workspaceId\": \"GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c\",\n        \"parentId\": null,\n        \"parentModel\": \"workspace\"\n      },\n      {\n        \"createdAt\": \"2025-05-16T16:48:12.298\",\n        \"id\": \"GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7folder\",\n        \"model\": \"environment\",\n        \"name\": \"Folder Environment\",\n        \"parentId\": \"GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7\",\n        \"parentModel\": \"folder\",\n        \"public\": true,\n        \"updatedAt\": \"2025-05-16T16:49:02.427\",\n        \"variables\": [\n          {\n            \"enabled\": true,\n            \"name\": \"folder_env_var\",\n            \"value\": \"testing\"\n          }\n        ],\n        \"workspaceId\": \"GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c\"\n      }\n    ],\n    \"folders\": [\n      {\n        \"createdAt\": \"2025-05-16T16:48:12.298\",\n        \"folderId\": null,\n        \"id\": \"GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7\",\n        \"model\": \"folder\",\n        \"name\": \"My Folder\",\n        \"sortPriority\": -1747414092298,\n        \"updatedAt\": \"2025-05-16T16:49:02.427\",\n        \"workspaceId\": \"GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c\",\n        \"authentication\": {},\n        \"authenticationType\": null,\n        \"headers\": [\n          {\n            \"enabled\": true,\n            \"name\": \"foo\",\n            \"value\": \"bar\"\n          }\n        ]\n      }\n    ],\n    \"grpcRequests\": [],\n    \"httpRequests\": [\n      {\n        \"authentication\": {},\n        \"authenticationType\": null,\n        \"body\": {},\n        \"bodyType\": null,\n        \"createdAt\": \"2025-05-14T04:45:28.502\",\n        \"folderId\": \"GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7\",\n        \"headers\": [\n          {\n            \"enabled\": true,\n            \"name\": \"User-Agent\",\n            \"value\": \"insomnia/11.1.0\"\n          }\n        ],\n        \"id\": \"GENERATE_ID::req_9a80320365ac4509ade406359dbc6a71\",\n        \"method\": \"GET\",\n        \"model\": \"http_request\",\n        \"name\": \"New Request\",\n        \"sortPriority\": -1747414129276,\n        \"updatedAt\": \"2025-05-16T16:48:49.313\",\n        \"url\": \"https://httpbin.org/post\",\n        \"urlParameters\": [],\n        \"workspaceId\": \"GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c\"\n      },\n      {\n        \"authentication\": {},\n        \"authenticationType\": null,\n        \"body\": {},\n        \"bodyType\": null,\n        \"createdAt\": \"2025-05-16T16:49:20.497\",\n        \"folderId\": null,\n        \"headers\": [\n          {\n            \"enabled\": true,\n            \"name\": \"User-Agent\",\n            \"value\": \"insomnia/11.1.0\"\n          }\n        ],\n        \"id\": \"GENERATE_ID::req_e3f8cdbd58784a539dd4c1e127d73451\",\n        \"method\": \"GET\",\n        \"model\": \"http_request\",\n        \"name\": \"New Request\",\n        \"sortPriority\": -1747414160498,\n        \"updatedAt\": \"2025-05-16T16:49:20.497\",\n        \"urlParameters\": [],\n        \"workspaceId\": \"GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c\"\n      }\n    ],\n    \"websocketRequests\": [],\n    \"workspaces\": [\n      {\n        \"createdAt\": \"2025-05-14T04:45:24.902\",\n        \"id\": \"GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c\",\n        \"model\": \"workspace\",\n        \"name\": \"Debugging\",\n        \"updatedAt\": \"2025-05-14T04:45:24.902\",\n        \"authentication\": {},\n        \"authenticationType\": null,\n        \"headers\": []\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "plugins/importer-insomnia/tests/fixtures/version-5.input.yaml",
    "content": "type: collection.insomnia.rest/5.0\nname: Dummy\nmeta:\n  id: wrk_c1eacfa750a04f3ea9985ef28043fa53\n  created: 1746799305927\n  modified: 1746843054272\n  description: This is the description\ncollection:\n  - name: Top Level\n    meta:\n      id: fld_42eb2e2bb22b4cedacbd3d057634e80c\n      created: 1736781404718\n      modified: 1736781404718\n      sortKey: -1736781404718\n    children:\n      - url: \"{{ _.BASE_URL }}/foo/:id\"\n        name: New Request\n        meta:\n          id: req_d72fff2a6b104b91a2ebe9de9edd2785\n          created: 1736781406672\n          modified: 1736781473176\n          isPrivate: false\n          description: My description of the request\n          sortKey: -1736781406672\n        method: GET\n        body:\n          mimeType: multipart/form-data\n          params:\n            - id: pair_7c86036ae8ef499dbbc0b43d0800c5a3\n              name: form\n              value: data\n              disabled: false\n        parameters:\n          - id: pair_b22f6ff611cd4250a6e405ca7b713d09\n            name: query\n            value: qqq\n            disabled: false\n        headers:\n          - name: Content-Type\n            value: multipart/form-data\n            id: pair_4af845963bd14256b98716617971eecd\n          - name: User-Agent\n            value: insomnia/10.3.0\n            id: pair_535ffd00ce48462cb1b7258832ade65a\n          - id: pair_ab4b870278e943cba6babf5a73e213e3\n            name: X-Header\n            value: xxxx\n            disabled: false\n          - id: pair_ab4b870278e943cba6babf5a73e213e3\n            name: \"{{ _.ApiHeaderName }}\"\n            value: \"{{ _.ApiKey }}\"\n            disabled: false\n        authentication:\n          type: basic\n          useISO88591: false\n          disabled: false\n          username: user\n          password: pass\n        settings:\n          renderRequestBody: true\n          encodeUrl: true\n          followRedirects: global\n          cookies:\n            send: true\n            store: true\n          rebuildPath: true\n        pathParameters:\n          - name: id\n            value: iii\n  - url: grpcb.in:9000\n    name: New Request\n    meta:\n      id: greq_06d659324df94504a4d64632be7106b3\n      created: 1746799344864\n      modified: 1746799544082\n      isPrivate: false\n      sortKey: -1746799344864\n    body:\n      text: |-\n        {\n        \t\"greeting\": \"Greg\"\n        }\n    protoFileId: pf_9d45b0dfaccc4bcc9d930746716786c5\n    protoMethodName: /hello.HelloService/SayHello\n    reflectionApi:\n      enabled: false\n      url: https://buf.build\n      module: buf.build/connectrpc/eliza\n  - url: wss://echo.websocket.org\n    name: New WebSocket Request\n    meta:\n      id: ws-req_5d1a4c7c79494743962e5176f6add270\n      created: 1746799553909\n      modified: 1746887120958\n      sortKey: -1746799553909\n    settings:\n      encodeUrl: true\n      followRedirects: global\n      cookies:\n        send: true\n        store: true\n    authentication:\n      type: basic\n      useISO88591: false\n      disabled: false\n      username: user\n      password: password\n    headers:\n      - name: User-Agent\n        value: insomnia/11.1.0\ncookieJar:\n  name: Default Jar\n  meta:\n    id: jar_663d5741b072441aa2709a6113371510\n    created: 1736781343768\n    modified: 1736781343768\nenvironments:\n  name: Base Environment\n  meta:\n    id: env_20945044d3c8497ca8b717bef750987e\n    created: 1736781343767\n    modified: 1736781355209\n    isPrivate: false\n  data:\n    BASE_VAR: hello\n  subEnvironments:\n    - name: Production\n      meta:\n        id: env_6f7728bb7fc04d558d668e954d756ea2\n        created: 1736781358515\n        modified: 1736781394705\n        isPrivate: false\n        sortKey: 1736781358515\n      data:\n        BASE_URL: https://api.yaak.app\n      color: \"#f22c2c\"\n    - name: Staging\n      meta:\n        id: env_976a8b6eb5d44fb6a20150f65c32d243\n        created: 1736781374707\n        modified: 1736781391078\n        isPrivate: false\n        sortKey: 1736781358565\n      data:\n        BASE_URL: https://api.staging.yaak.app\n      color: \"#206fac\"\n"
  },
  {
    "path": "plugins/importer-insomnia/tests/fixtures/version-5.output.json",
    "content": "{\n  \"resources\": {\n    \"environments\": [\n      {\n        \"createdAt\": \"2025-01-13T15:15:43.767\",\n        \"updatedAt\": \"2025-01-13T15:15:55.209\",\n        \"public\": true,\n        \"id\": \"GENERATE_ID::env_20945044d3c8497ca8b717bef750987e\",\n        \"model\": \"environment\",\n        \"name\": \"Base Environment\",\n        \"parentId\": null,\n        \"parentModel\": \"workspace\",\n        \"variables\": [\n          {\n            \"enabled\": true,\n            \"name\": \"BASE_VAR\",\n            \"value\": \"hello\"\n          }\n        ],\n        \"workspaceId\": \"GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53\"\n      },\n      {\n        \"createdAt\": \"2025-01-13T15:15:58.515\",\n        \"updatedAt\": \"2025-01-13T15:16:34.705\",\n        \"public\": true,\n        \"id\": \"GENERATE_ID::env_6f7728bb7fc04d558d668e954d756ea2\",\n        \"model\": \"environment\",\n        \"name\": \"Production\",\n        \"parentId\": null,\n        \"parentModel\": \"environment\",\n        \"sortPriority\": 1736781358515,\n        \"variables\": [\n          {\n            \"enabled\": true,\n            \"name\": \"BASE_URL\",\n            \"value\": \"https://api.yaak.app\"\n          }\n        ],\n        \"workspaceId\": \"GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53\"\n      },\n      {\n        \"createdAt\": \"2025-01-13T15:16:14.707\",\n        \"updatedAt\": \"2025-01-13T15:16:31.078\",\n        \"public\": true,\n        \"parentId\": null,\n        \"parentModel\": \"environment\",\n        \"id\": \"GENERATE_ID::env_976a8b6eb5d44fb6a20150f65c32d243\",\n        \"model\": \"environment\",\n        \"name\": \"Staging\",\n        \"sortPriority\": 1736781358565,\n        \"variables\": [\n          {\n            \"enabled\": true,\n            \"name\": \"BASE_URL\",\n            \"value\": \"https://api.staging.yaak.app\"\n          }\n        ],\n        \"workspaceId\": \"GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53\"\n      }\n    ],\n    \"folders\": [\n      {\n        \"createdAt\": \"2025-01-13T15:16:44.718\",\n        \"updatedAt\": \"2025-01-13T15:16:44.718\",\n        \"folderId\": null,\n        \"id\": \"GENERATE_ID::fld_42eb2e2bb22b4cedacbd3d057634e80c\",\n        \"model\": \"folder\",\n        \"name\": \"Top Level\",\n        \"sortPriority\": -1736781404718,\n        \"workspaceId\": \"GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53\",\n        \"authentication\": {},\n        \"authenticationType\": null,\n        \"headers\": []\n      }\n    ],\n    \"grpcRequests\": [\n      {\n        \"model\": \"grpc_request\",\n        \"createdAt\": \"2025-05-09T14:02:24.864\",\n        \"folderId\": null,\n        \"id\": \"GENERATE_ID::greq_06d659324df94504a4d64632be7106b3\",\n        \"message\": \"{\\n\\t\\\"greeting\\\": \\\"Greg\\\"\\n}\",\n        \"metadata\": [],\n        \"method\": \"SayHello\",\n        \"name\": \"New Request\",\n        \"service\": \"hello.HelloService\",\n        \"sortPriority\": -1746799344864,\n        \"updatedAt\": \"2025-05-09T14:05:44.082\",\n        \"url\": \"grpcb.in:9000\",\n        \"workspaceId\": \"GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53\"\n      }\n    ],\n    \"httpRequests\": [\n      {\n        \"authentication\": {\n          \"password\": \"pass\",\n          \"username\": \"user\"\n        },\n        \"authenticationType\": \"basic\",\n        \"body\": {\n          \"form\": [\n            {\n              \"enabled\": true,\n              \"file\": null,\n              \"name\": \"form\",\n              \"value\": \"data\"\n            }\n          ]\n        },\n        \"bodyType\": \"multipart/form-data\",\n        \"createdAt\": \"2025-01-13T15:16:46.672\",\n        \"updatedAt\": \"2025-01-13T15:17:53.176\",\n        \"description\": \"My description of the request\",\n        \"folderId\": \"GENERATE_ID::fld_42eb2e2bb22b4cedacbd3d057634e80c\",\n        \"headers\": [\n          {\n            \"enabled\": true,\n            \"name\": \"Content-Type\",\n            \"value\": \"multipart/form-data\"\n          },\n          {\n            \"enabled\": true,\n            \"name\": \"User-Agent\",\n            \"value\": \"insomnia/10.3.0\"\n          },\n          {\n            \"enabled\": true,\n            \"name\": \"X-Header\",\n            \"value\": \"xxxx\"\n          },\n          {\n            \"enabled\": true,\n            \"name\": \"${[ApiHeaderName ]}\",\n            \"value\": \"${[ApiKey ]}\"\n          }\n        ],\n        \"id\": \"GENERATE_ID::req_d72fff2a6b104b91a2ebe9de9edd2785\",\n        \"method\": \"GET\",\n        \"model\": \"http_request\",\n        \"name\": \"New Request\",\n        \"sortPriority\": -1736781406672,\n        \"url\": \"${[BASE_URL ]}/foo/:id\",\n        \"urlParameters\": [\n          {\n            \"name\": \"query\",\n            \"value\": \"qqq\",\n            \"enabled\": true\n          }\n        ],\n        \"workspaceId\": \"GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53\"\n      }\n    ],\n    \"websocketRequests\": [\n      {\n        \"id\": \"GENERATE_ID::ws-req_5d1a4c7c79494743962e5176f6add270\",\n        \"createdAt\": \"2025-05-09T14:05:53.909\",\n        \"updatedAt\": \"2025-05-10T14:25:20.958\",\n        \"message\": \"\",\n        \"model\": \"websocket_request\",\n        \"name\": \"New WebSocket Request\",\n        \"sortPriority\": -1746799553909,\n        \"authenticationType\": \"basic\",\n        \"authentication\": {\n          \"password\": \"password\",\n          \"username\": \"user\"\n        },\n        \"folderId\": null,\n        \"headers\": [\n          {\n            \"enabled\": true,\n            \"name\": \"User-Agent\",\n            \"value\": \"insomnia/11.1.0\"\n          }\n        ],\n        \"url\": \"wss://echo.websocket.org\",\n        \"workspaceId\": \"GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53\"\n      }\n    ],\n    \"workspaces\": [\n      {\n        \"createdAt\": \"2025-05-09T14:01:45.927\",\n        \"updatedAt\": \"2025-05-10T02:10:54.272\",\n        \"description\": \"This is the description\",\n        \"id\": \"GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53\",\n        \"model\": \"workspace\",\n        \"name\": \"Dummy\",\n        \"authentication\": {},\n        \"authenticationType\": null,\n        \"headers\": []\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "plugins/importer-insomnia/tests/index.test.ts",
    "content": "import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { describe, expect, test } from \"vite-plus/test\";\nimport YAML from \"yaml\";\nimport { convertInsomnia } from \"../src\";\n\ndescribe(\"importer-yaak\", () => {\n  const p = path.join(__dirname, \"fixtures\");\n  const fixtures = fs.readdirSync(p);\n\n  for (const fixture of fixtures) {\n    if (fixture.includes(\".output\")) {\n      continue;\n    }\n\n    test(`Imports ${fixture}`, () => {\n      const contents = fs.readFileSync(path.join(p, fixture), \"utf-8\");\n      const expected = fs.readFileSync(\n        path.join(p, fixture.replace(/.input\\..*/, \".output.json\")),\n        \"utf-8\",\n      );\n      const result = convertInsomnia(contents);\n      // console.log(JSON.stringify(result, null, 2))\n      expect(result).toEqual(parseJsonOrYaml(expected));\n    });\n  }\n});\n\nfunction parseJsonOrYaml(text: string): unknown {\n  try {\n    return JSON.parse(text);\n  } catch {\n    return YAML.parse(text);\n  }\n}\n"
  },
  {
    "path": "plugins/importer-insomnia/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/importer-openapi/package.json",
    "content": "{\n  \"name\": \"@yaak/importer-openapi\",\n  \"displayName\": \"OpenAPI Importer\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Import API specifications from OpenAPI/Swagger format\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  },\n  \"dependencies\": {\n    \"openapi-to-postmanv2\": \"^5.8.0\",\n    \"yaml\": \"^2.4.2\"\n  },\n  \"devDependencies\": {\n    \"@types/openapi-to-postmanv2\": \"^5.0.0\"\n  }\n}\n"
  },
  {
    "path": "plugins/importer-openapi/src/index.ts",
    "content": "import { convertPostman } from \"@yaak/importer-postman/src\";\nimport type { Context, PluginDefinition } from \"@yaakapp/api\";\nimport type { ImportPluginResponse } from \"@yaakapp/api/lib/plugins/ImporterPlugin\";\nimport { convert } from \"openapi-to-postmanv2\";\n\nexport const plugin: PluginDefinition = {\n  importer: {\n    name: \"OpenAPI\",\n    description: \"Import OpenAPI collections\",\n    onImport(_ctx: Context, args: { text: string }) {\n      return convertOpenApi(args.text);\n    },\n  },\n};\n\nexport async function convertOpenApi(contents: string): Promise<ImportPluginResponse | undefined> {\n  // oxlint-disable-next-line no-explicit-any\n  let postmanCollection: any;\n  try {\n    postmanCollection = await new Promise((resolve, reject) => {\n      // oxlint-disable-next-line no-explicit-any\n      convert({ type: \"string\", data: contents }, {}, (err, result: any) => {\n        if (err != null) reject(err);\n\n        if (Array.isArray(result.output) && result.output.length > 0) {\n          resolve(result.output[0].data);\n        }\n      });\n    });\n  } catch {\n    // Probably not an OpenAPI file, so skip it\n    return undefined;\n  }\n\n  return convertPostman(JSON.stringify(postmanCollection));\n}\n"
  },
  {
    "path": "plugins/importer-openapi/tests/fixtures/petstore.yaml",
    "content": "openapi: 3.0.2\nservers:\n  - url: /v3\ninfo:\n  description: |-\n    This is a sample Pet Store Server based on the OpenAPI 3.0 specification.  You can find out more about\n    Swagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\n    You can now help us improve the API whether it's by making changes to the definition itself or to the code.\n    That way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\n    Some useful links:\n    - [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n    - [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)\n  version: 1.0.20-SNAPSHOT\n  title: Swagger Petstore - OpenAPI 3.0\n  termsOfService: \"http://swagger.io/terms/\"\n  contact:\n    email: apiteam@swagger.io\n  license:\n    name: Apache 2.0\n    url: \"http://www.apache.org/licenses/LICENSE-2.0.html\"\ntags:\n  - name: pet\n    description: Everything about your Pets\n    externalDocs:\n      description: Find out more\n      url: \"http://swagger.io\"\n  - name: store\n    description: Access to Petstore orders\n    externalDocs:\n      description: Find out more about our store\n      url: \"http://swagger.io\"\n  - name: user\n    description: Operations about user\npaths:\n  /pet:\n    post:\n      tags:\n        - pet\n      summary: Add a new pet to the store\n      description: Add a new pet to the store\n      operationId: addPet\n      responses:\n        \"200\":\n          description: Successful operation\n          content:\n            application/xml:\n              schema:\n                $ref: \"#/components/schemas/Pet\"\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Pet\"\n        \"405\":\n          description: Invalid input\n      security:\n        - petstore_auth:\n            - \"write:pets\"\n            - \"read:pets\"\n      requestBody:\n        description: Create a new pet in the store\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/Pet\"\n          application/xml:\n            schema:\n              $ref: \"#/components/schemas/Pet\"\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: \"#/components/schemas/Pet\"\n    put:\n      tags:\n        - pet\n      summary: Update an existing pet\n      description: Update an existing pet by Id\n      operationId: updatePet\n      responses:\n        \"200\":\n          description: Successful operation\n          content:\n            application/xml:\n              schema:\n                $ref: \"#/components/schemas/Pet\"\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Pet\"\n        \"400\":\n          description: Invalid ID supplied\n        \"404\":\n          description: Pet not found\n        \"405\":\n          description: Validation exception\n      security:\n        - petstore_auth:\n            - \"write:pets\"\n            - \"read:pets\"\n      requestBody:\n        description: Update an existent pet in the store\n        required: true\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/Pet\"\n          application/xml:\n            schema:\n              $ref: \"#/components/schemas/Pet\"\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: \"#/components/schemas/Pet\"\n  /pet/findByStatus:\n    get:\n      tags:\n        - pet\n      summary: Finds Pets by status\n      description: Multiple status values can be provided with comma separated strings\n      operationId: findPetsByStatus\n      parameters:\n        - name: status\n          in: query\n          description: Status values that need to be considered for filter\n          required: false\n          explode: true\n          schema:\n            type: string\n            enum:\n              - available\n              - pending\n              - sold\n            default: available\n      responses:\n        \"200\":\n          description: successful operation\n          content:\n            application/xml:\n              schema:\n                type: array\n                items:\n                  $ref: \"#/components/schemas/Pet\"\n            application/json:\n              schema:\n                type: array\n                items:\n                  $ref: \"#/components/schemas/Pet\"\n        \"400\":\n          description: Invalid status value\n      security:\n        - petstore_auth:\n            - \"write:pets\"\n            - \"read:pets\"\n  /pet/findByTags:\n    get:\n      tags:\n        - pet\n      summary: Finds Pets by tags\n      description: >-\n        Multiple tags can be provided with comma separated strings. Use tag1,\n        tag2, tag3 for testing.\n      operationId: findPetsByTags\n      parameters:\n        - name: tags\n          in: query\n          description: Tags to filter by\n          required: false\n          explode: true\n          schema:\n            type: array\n            items:\n              type: string\n      responses:\n        \"200\":\n          description: successful operation\n          content:\n            application/xml:\n              schema:\n                type: array\n                items:\n                  $ref: \"#/components/schemas/Pet\"\n            application/json:\n              schema:\n                type: array\n                items:\n                  $ref: \"#/components/schemas/Pet\"\n        \"400\":\n          description: Invalid tag value\n      security:\n        - petstore_auth:\n            - \"write:pets\"\n            - \"read:pets\"\n  \"/pet/{petId}\":\n    get:\n      tags:\n        - pet\n      summary: Find pet by ID\n      description: Returns a single pet\n      operationId: getPetById\n      parameters:\n        - name: petId\n          in: path\n          description: ID of pet to return\n          required: true\n          schema:\n            type: integer\n            format: int64\n      responses:\n        \"200\":\n          description: successful operation\n          content:\n            application/xml:\n              schema:\n                $ref: \"#/components/schemas/Pet\"\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Pet\"\n        \"400\":\n          description: Invalid ID supplied\n        \"404\":\n          description: Pet not found\n      security:\n        - api_key: []\n        - petstore_auth:\n            - \"write:pets\"\n            - \"read:pets\"\n    post:\n      tags:\n        - pet\n      summary: Updates a pet in the store with form data\n      description: \"\"\n      operationId: updatePetWithForm\n      parameters:\n        - name: petId\n          in: path\n          description: ID of pet that needs to be updated\n          required: true\n          schema:\n            type: integer\n            format: int64\n        - name: name\n          in: query\n          description: Name of pet that needs to be updated\n          schema:\n            type: string\n        - name: status\n          in: query\n          description: Status of pet that needs to be updated\n          schema:\n            type: string\n      responses:\n        \"405\":\n          description: Invalid input\n      security:\n        - petstore_auth:\n            - \"write:pets\"\n            - \"read:pets\"\n    delete:\n      tags:\n        - pet\n      summary: Deletes a pet\n      description: \"\"\n      operationId: deletePet\n      parameters:\n        - name: api_key\n          in: header\n          description: \"\"\n          required: false\n          schema:\n            type: string\n        - name: petId\n          in: path\n          description: Pet id to delete\n          required: true\n          schema:\n            type: integer\n            format: int64\n      responses:\n        \"400\":\n          description: Invalid pet value\n      security:\n        - petstore_auth:\n            - \"write:pets\"\n            - \"read:pets\"\n  \"/pet/{petId}/uploadImage\":\n    post:\n      tags:\n        - pet\n      summary: uploads an image\n      description: \"\"\n      operationId: uploadFile\n      parameters:\n        - name: petId\n          in: path\n          description: ID of pet to update\n          required: true\n          schema:\n            type: integer\n            format: int64\n        - name: additionalMetadata\n          in: query\n          description: Additional Metadata\n          required: false\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: successful operation\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/ApiResponse\"\n      security:\n        - petstore_auth:\n            - \"write:pets\"\n            - \"read:pets\"\n      requestBody:\n        content:\n          application/octet-stream:\n            schema:\n              type: string\n              format: binary\n  /store/inventory:\n    get:\n      tags:\n        - store\n      summary: Returns pet inventories by status\n      description: Returns a map of status codes to quantities\n      operationId: getInventory\n      x-swagger-router-controller: OrderController\n      responses:\n        \"200\":\n          description: successful operation\n          content:\n            application/json:\n              schema:\n                type: object\n                additionalProperties:\n                  type: integer\n                  format: int32\n      security:\n        - api_key: []\n  /store/order:\n    post:\n      tags:\n        - store\n      summary: Place an order for a pet\n      description: Place a new order in the store\n      operationId: placeOrder\n      x-swagger-router-controller: OrderController\n      responses:\n        \"200\":\n          description: successful operation\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Order\"\n        \"405\":\n          description: Invalid input\n      requestBody:\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/Order\"\n          application/xml:\n            schema:\n              $ref: \"#/components/schemas/Order\"\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: \"#/components/schemas/Order\"\n  \"/store/order/{orderId}\":\n    get:\n      tags:\n        - store\n      summary: Find purchase order by ID\n      x-swagger-router-controller: OrderController\n      description: >-\n        For valid response try integer IDs with value <= 5 or > 10. Other values\n        will generate exceptions.\n      operationId: getOrderById\n      parameters:\n        - name: orderId\n          in: path\n          description: ID of order that needs to be fetched\n          required: true\n          schema:\n            type: integer\n            format: int64\n      responses:\n        \"200\":\n          description: successful operation\n          content:\n            application/xml:\n              schema:\n                $ref: \"#/components/schemas/Order\"\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/Order\"\n        \"400\":\n          description: Invalid ID supplied\n        \"404\":\n          description: Order not found\n    delete:\n      tags:\n        - store\n      summary: Delete purchase order by ID\n      x-swagger-router-controller: OrderController\n      description: >-\n        For valid response try integer IDs with value < 1000. Anything above\n        1000 or nonintegers will generate API errors\n      operationId: deleteOrder\n      parameters:\n        - name: orderId\n          in: path\n          description: ID of the order that needs to be deleted\n          required: true\n          schema:\n            type: integer\n            format: int64\n      responses:\n        \"400\":\n          description: Invalid ID supplied\n        \"404\":\n          description: Order not found\n  /user:\n    post:\n      tags:\n        - user\n      summary: Create user\n      description: This can only be done by the logged in user.\n      operationId: createUser\n      responses:\n        default:\n          description: successful operation\n          content:\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/User\"\n            application/xml:\n              schema:\n                $ref: \"#/components/schemas/User\"\n      requestBody:\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/User\"\n          application/xml:\n            schema:\n              $ref: \"#/components/schemas/User\"\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: \"#/components/schemas/User\"\n        description: Created user object\n  /user/createWithList:\n    post:\n      tags:\n        - user\n      summary: Creates list of users with given input array\n      description: \"Creates list of users with given input array\"\n      x-swagger-router-controller: UserController\n      operationId: createUsersWithListInput\n      responses:\n        \"200\":\n          description: Successful operation\n          content:\n            application/xml:\n              schema:\n                $ref: \"#/components/schemas/User\"\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/User\"\n        default:\n          description: successful operation\n      requestBody:\n        content:\n          application/json:\n            schema:\n              type: array\n              items:\n                $ref: \"#/components/schemas/User\"\n  /user/login:\n    get:\n      tags:\n        - user\n      summary: Logs user into the system\n      description: \"\"\n      operationId: loginUser\n      parameters:\n        - name: username\n          in: query\n          description: The user name for login\n          required: false\n          schema:\n            type: string\n        - name: password\n          in: query\n          description: The password for login in clear text\n          required: false\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: successful operation\n          headers:\n            X-Rate-Limit:\n              description: calls per hour allowed by the user\n              schema:\n                type: integer\n                format: int32\n            X-Expires-After:\n              description: date in UTC when token expires\n              schema:\n                type: string\n                format: date-time\n          content:\n            application/xml:\n              schema:\n                type: string\n            application/json:\n              schema:\n                type: string\n        \"400\":\n          description: Invalid username/password supplied\n  /user/logout:\n    get:\n      tags:\n        - user\n      summary: Logs out current logged in user session\n      description: \"\"\n      operationId: logoutUser\n      parameters: []\n      responses:\n        default:\n          description: successful operation\n  \"/user/{username}\":\n    get:\n      tags:\n        - user\n      summary: Get user by user name\n      description: \"\"\n      operationId: getUserByName\n      parameters:\n        - name: username\n          in: path\n          description: \"The name that needs to be fetched. Use user1 for testing. \"\n          required: true\n          schema:\n            type: string\n      responses:\n        \"200\":\n          description: successful operation\n          content:\n            application/xml:\n              schema:\n                $ref: \"#/components/schemas/User\"\n            application/json:\n              schema:\n                $ref: \"#/components/schemas/User\"\n        \"400\":\n          description: Invalid username supplied\n        \"404\":\n          description: User not found\n    put:\n      tags:\n        - user\n      summary: Update user\n      x-swagger-router-controller: UserController\n      description: This can only be done by the logged in user.\n      operationId: updateUser\n      parameters:\n        - name: username\n          in: path\n          description: name that needs to be updated\n          required: true\n          schema:\n            type: string\n      responses:\n        default:\n          description: successful operation\n      requestBody:\n        description: Update an existent user in the store\n        content:\n          application/json:\n            schema:\n              $ref: \"#/components/schemas/User\"\n          application/xml:\n            schema:\n              $ref: \"#/components/schemas/User\"\n          application/x-www-form-urlencoded:\n            schema:\n              $ref: \"#/components/schemas/User\"\n    delete:\n      tags:\n        - user\n      summary: Delete user\n      description: This can only be done by the logged in user.\n      operationId: deleteUser\n      parameters:\n        - name: username\n          in: path\n          description: The name that needs to be deleted\n          required: true\n          schema:\n            type: string\n      responses:\n        \"400\":\n          description: Invalid username supplied\n        \"404\":\n          description: User not found\nexternalDocs:\n  description: Find out more about Swagger\n  url: \"http://swagger.io\"\ncomponents:\n  schemas:\n    Order:\n      x-swagger-router-model: io.swagger.petstore.model.Order\n      properties:\n        id:\n          type: integer\n          format: int64\n          example: 10\n        petId:\n          type: integer\n          format: int64\n          example: 198772\n        quantity:\n          type: integer\n          format: int32\n          example: 7\n        shipDate:\n          type: string\n          format: date-time\n        status:\n          type: string\n          description: Order Status\n          enum:\n            - placed\n            - approved\n            - delivered\n          example: approved\n        complete:\n          type: boolean\n      xml:\n        name: order\n      type: object\n    Customer:\n      properties:\n        id:\n          type: integer\n          format: int64\n          example: 100000\n        username:\n          type: string\n          example: fehguy\n        address:\n          type: array\n          items:\n            $ref: \"#/components/schemas/Address\"\n          xml:\n            wrapped: true\n            name: addresses\n      xml:\n        name: customer\n      type: object\n    Address:\n      properties:\n        street:\n          type: string\n          example: 437 Lytton\n        city:\n          type: string\n          example: Palo Alto\n        state:\n          type: string\n          example: CA\n        zip:\n          type: string\n          example: 94301\n      xml:\n        name: address\n      type: object\n    Category:\n      x-swagger-router-model: io.swagger.petstore.model.Category\n      properties:\n        id:\n          type: integer\n          format: int64\n          example: 1\n        name:\n          type: string\n          example: Dogs\n      xml:\n        name: category\n      type: object\n    User:\n      x-swagger-router-model: io.swagger.petstore.model.User\n      properties:\n        id:\n          type: integer\n          format: int64\n          example: 10\n        username:\n          type: string\n          example: theUser\n        firstName:\n          type: string\n          example: John\n        lastName:\n          type: string\n          example: James\n        email:\n          type: string\n          example: john@email.com\n        password:\n          type: string\n          example: 12345\n        phone:\n          type: string\n          example: 12345\n        userStatus:\n          type: integer\n          format: int32\n          example: 1\n          description: User Status\n      xml:\n        name: user\n      type: object\n    Tag:\n      x-swagger-router-model: io.swagger.petstore.model.Tag\n      properties:\n        id:\n          type: integer\n          format: int64\n        name:\n          type: string\n      xml:\n        name: tag\n      type: object\n    Pet:\n      x-swagger-router-model: io.swagger.petstore.model.Pet\n      required:\n        - name\n        - photoUrls\n      properties:\n        id:\n          type: integer\n          format: int64\n          example: 10\n        name:\n          type: string\n          example: doggie\n        category:\n          $ref: \"#/components/schemas/Category\"\n        photoUrls:\n          type: array\n          xml:\n            wrapped: true\n          items:\n            type: string\n            xml:\n              name: photoUrl\n        tags:\n          type: array\n          xml:\n            wrapped: true\n          items:\n            $ref: \"#/components/schemas/Tag\"\n            xml:\n              name: tag\n        status:\n          type: string\n          description: pet status in the store\n          enum:\n            - available\n            - pending\n            - sold\n      xml:\n        name: pet\n      type: object\n    ApiResponse:\n      properties:\n        code:\n          type: integer\n          format: int32\n        type:\n          type: string\n        message:\n          type: string\n      xml:\n        name: \"##default\"\n      type: object\n  requestBodies:\n    Pet:\n      content:\n        application/json:\n          schema:\n            $ref: \"#/components/schemas/Pet\"\n        application/xml:\n          schema:\n            $ref: \"#/components/schemas/Pet\"\n      description: Pet object that needs to be added to the store\n    UserArray:\n      content:\n        application/json:\n          schema:\n            type: array\n            items:\n              $ref: \"#/components/schemas/User\"\n      description: List of user object\n  securitySchemes:\n    petstore_auth:\n      type: oauth2\n      flows:\n        implicit:\n          authorizationUrl: \"https://petstore.swagger.io/oauth/authorize\"\n          scopes:\n            \"write:pets\": modify pets in your account\n            \"read:pets\": read your pets\n    api_key:\n      type: apiKey\n      name: api_key\n      in: header\n"
  },
  {
    "path": "plugins/importer-openapi/tests/index.test.ts",
    "content": "import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { describe, expect, test } from \"vite-plus/test\";\nimport { convertOpenApi } from \"../src\";\n\ndescribe(\"importer-openapi\", () => {\n  const p = path.join(__dirname, \"fixtures\");\n  const fixtures = fs.readdirSync(p);\n\n  test(\"Maps operation description to request description\", async () => {\n    const imported = await convertOpenApi(\n      JSON.stringify({\n        openapi: \"3.0.0\",\n        info: { title: \"Description Test\", version: \"1.0.0\" },\n        paths: {\n          \"/klanten\": {\n            get: {\n              description: \"Lijst van klanten\",\n              responses: { \"200\": { description: \"ok\" } },\n            },\n          },\n        },\n      }),\n    );\n\n    expect(imported?.resources.httpRequests).toEqual([\n      expect.objectContaining({\n        description: \"Lijst van klanten\",\n      }),\n    ]);\n  });\n\n  test(\"Skips invalid file\", async () => {\n    const imported = await convertOpenApi(\"{}\");\n    expect(imported).toBeUndefined();\n  });\n\n  for (const fixture of fixtures) {\n    test(`Imports ${fixture}`, async () => {\n      const contents = fs.readFileSync(path.join(p, fixture), \"utf-8\");\n      const imported = await convertOpenApi(contents);\n      expect(imported?.resources.workspaces).toEqual([\n        expect.objectContaining({\n          name: \"Swagger Petstore - OpenAPI 3.0\",\n          description: expect.stringContaining(\"This is a sample Pet Store Server\"),\n        }),\n      ]);\n      expect(imported?.resources.httpRequests.length).toBe(19);\n      expect(imported?.resources.folders.length).toBe(7);\n    });\n  }\n});\n"
  },
  {
    "path": "plugins/importer-openapi/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/importer-postman/package.json",
    "content": "{\n  \"name\": \"@yaak/importer-postman\",\n  \"displayName\": \"Postman Importer\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Import collections from Postman\",\n  \"main\": \"./build/index.js\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  }\n}\n"
  },
  {
    "path": "plugins/importer-postman/src/index.ts",
    "content": "/* oxlint-disable no-base-to-string */\nimport type {\n  Context,\n  Environment,\n  Folder,\n  HttpRequest,\n  HttpRequestHeader,\n  HttpUrlParameter,\n  PartialImportResources,\n  PluginDefinition,\n  Workspace,\n} from \"@yaakapp/api\";\nimport type { ImportPluginResponse } from \"@yaakapp/api/lib/plugins/ImporterPlugin\";\n\nconst POSTMAN_2_1_0_SCHEMA = \"https://schema.getpostman.com/json/collection/v2.1.0/collection.json\";\nconst POSTMAN_2_0_0_SCHEMA = \"https://schema.getpostman.com/json/collection/v2.0.0/collection.json\";\nconst VALID_SCHEMAS = [POSTMAN_2_0_0_SCHEMA, POSTMAN_2_1_0_SCHEMA];\n\ntype AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;\n\ninterface ExportResources {\n  workspaces: AtLeast<Workspace, \"name\" | \"id\" | \"model\">[];\n  environments: AtLeast<Environment, \"name\" | \"id\" | \"model\" | \"workspaceId\">[];\n  httpRequests: AtLeast<HttpRequest, \"name\" | \"id\" | \"model\" | \"workspaceId\">[];\n  folders: AtLeast<Folder, \"name\" | \"id\" | \"model\" | \"workspaceId\">[];\n}\n\nexport const plugin: PluginDefinition = {\n  importer: {\n    name: \"Postman\",\n    description: \"Import postman collections\",\n    onImport(_ctx: Context, args: { text: string }) {\n      return convertPostman(args.text);\n    },\n  },\n};\n\nexport function convertPostman(contents: string): ImportPluginResponse | undefined {\n  const root = parseJSONToRecord(contents);\n  if (root == null) return;\n\n  const info = toRecord(root.info);\n  const isValidSchema = VALID_SCHEMAS.includes(\n    typeof info.schema === \"string\" ? info.schema : \"n/a\",\n  );\n  if (!isValidSchema || !Array.isArray(root.item)) {\n    return;\n  }\n\n  const globalAuth = importAuth(root.auth);\n\n  const exportResources: ExportResources = {\n    workspaces: [],\n    environments: [],\n    httpRequests: [],\n    folders: [],\n  };\n\n  const workspace: ExportResources[\"workspaces\"][0] = {\n    model: \"workspace\",\n    id: generateId(\"workspace\"),\n    name: info.name ? String(info.name) : \"Postman Import\",\n    description: importDescription(info.description),\n    ...globalAuth,\n  };\n  exportResources.workspaces.push(workspace);\n\n  // Create the base environment\n  const environment: ExportResources[\"environments\"][0] = {\n    model: \"environment\",\n    id: generateId(\"environment\"),\n    name: \"Global Variables\",\n    workspaceId: workspace.id,\n    parentModel: \"workspace\",\n    parentId: null,\n    variables:\n      toArray<{ key: string; value: string }>(root.variable).map((v) => ({\n        name: v.key,\n        value: v.value,\n      })) ?? [],\n  };\n  exportResources.environments.push(environment);\n\n  let sortPriorityIndex = 0;\n  const importItem = (v: Record<string, unknown>, folderId: string | null = null) => {\n    if (typeof v.name === \"string\" && Array.isArray(v.item)) {\n      const folder: ExportResources[\"folders\"][0] = {\n        model: \"folder\",\n        sortPriority: sortPriorityIndex++,\n        workspaceId: workspace.id,\n        id: generateId(\"folder\"),\n        name: v.name,\n        folderId,\n      };\n      exportResources.folders.push(folder);\n      for (const child of v.item) {\n        importItem(child, folder.id);\n      }\n    } else if (typeof v.name === \"string\" && \"request\" in v) {\n      const r = toRecord(v.request);\n      const bodyPatch = importBody(r.body);\n      const requestAuth = importAuth(r.auth);\n\n      const headers: HttpRequestHeader[] = toArray<{\n        key: string;\n        value: string;\n        disabled?: boolean;\n      }>(r.header).map((h) => {\n        return {\n          name: h.key,\n          value: h.value,\n          enabled: !h.disabled,\n        };\n      });\n\n      // Add body headers only if they don't already exist\n      for (const bodyPatchHeader of bodyPatch.headers) {\n        const existingHeader = headers.find(\n          (h) => h.name.toLowerCase() === bodyPatchHeader.name.toLowerCase(),\n        );\n        if (existingHeader) {\n          continue;\n        }\n        headers.push(bodyPatchHeader);\n      }\n\n      const { url, urlParameters } = convertUrl(r.url);\n\n      const request: ExportResources[\"httpRequests\"][0] = {\n        model: \"http_request\",\n        id: generateId(\"http_request\"),\n        workspaceId: workspace.id,\n        folderId,\n        name: v.name,\n        description: importDescription(r.description),\n        method: typeof r.method === \"string\" ? r.method : \"GET\",\n        url,\n        urlParameters,\n        body: bodyPatch.body,\n        bodyType: bodyPatch.bodyType,\n        sortPriority: sortPriorityIndex++,\n        headers,\n        ...requestAuth,\n      };\n      exportResources.httpRequests.push(request);\n    } else {\n      console.log(\"Unknown item\", v, folderId);\n    }\n  };\n\n  for (const item of root.item) {\n    importItem(item);\n  }\n\n  const resources = deleteUndefinedAttrs(\n    convertTemplateSyntax(exportResources),\n  ) as PartialImportResources;\n\n  return { resources };\n}\n\nfunction convertUrl(rawUrl: unknown): Pick<HttpRequest, \"url\" | \"urlParameters\"> {\n  if (typeof rawUrl === \"string\") {\n    return { url: rawUrl, urlParameters: [] };\n  }\n\n  const url = toRecord(rawUrl);\n\n  let v = \"\";\n\n  if (\"protocol\" in url && typeof url.protocol === \"string\") {\n    v += `${url.protocol}://`;\n  }\n\n  if (\"host\" in url) {\n    v += `${Array.isArray(url.host) ? url.host.join(\".\") : String(url.host)}`;\n  }\n\n  if (\"port\" in url && typeof url.port === \"string\") {\n    v += `:${url.port}`;\n  }\n\n  if (\"path\" in url && Array.isArray(url.path) && url.path.length > 0) {\n    v += `/${Array.isArray(url.path) ? url.path.join(\"/\") : url.path}`;\n  }\n\n  const params: HttpUrlParameter[] = [];\n  if (\"query\" in url && Array.isArray(url.query) && url.query.length > 0) {\n    for (const query of url.query) {\n      params.push({\n        name: query.key ?? \"\",\n        value: query.value ?? \"\",\n        enabled: !query.disabled,\n      });\n    }\n  }\n\n  if (\"variable\" in url && Array.isArray(url.variable) && url.variable.length > 0) {\n    for (const v of url.variable) {\n      params.push({\n        name: `:${v.key ?? \"\"}`,\n        value: v.value ?? \"\",\n        enabled: !v.disabled,\n      });\n    }\n  }\n\n  if (\"hash\" in url && typeof url.hash === \"string\") {\n    v += `#${url.hash}`;\n  }\n\n  // TODO: Implement url.variables (path variables)\n\n  return { url: v, urlParameters: params };\n}\n\nfunction importAuth(rawAuth: unknown): Pick<HttpRequest, \"authentication\" | \"authenticationType\"> {\n  const auth = toRecord<Record<string, string>>(rawAuth);\n\n  // Helper: Postman stores auth params as an array of { key, value, ... }\n  const pmArrayToObj = (v: unknown): Record<string, unknown> => {\n    if (!Array.isArray(v)) return toRecord(v);\n    const o: Record<string, unknown> = {};\n    for (const i of v) {\n      const ii = toRecord(i);\n      if (typeof ii.key === \"string\") {\n        o[ii.key] = ii.value;\n      }\n    }\n    return o;\n  };\n\n  const authType: string | undefined = auth.type ? String(auth.type) : undefined;\n\n  if (authType === \"noauth\") {\n    return {\n      authenticationType: \"none\",\n      authentication: {},\n    };\n  }\n\n  if (\"basic\" in auth && authType === \"basic\") {\n    const b = pmArrayToObj(auth.basic);\n    return {\n      authenticationType: \"basic\",\n      authentication: {\n        username: String(b.username ?? \"\"),\n        password: String(b.password ?? \"\"),\n      },\n    };\n  }\n\n  if (\"bearer\" in auth && authType === \"bearer\") {\n    const b = pmArrayToObj(auth.bearer);\n    // Postman uses key \"token\"\n    return {\n      authenticationType: \"bearer\",\n      authentication: {\n        token: String(b.token ?? \"\"),\n      },\n    };\n  }\n\n  if (\"awsv4\" in auth && authType === \"awsv4\") {\n    const a = pmArrayToObj(auth.awsv4);\n    return {\n      authenticationType: \"awsv4\",\n      authentication: {\n        accessKeyId: a.accessKey != null ? String(a.accessKey) : undefined,\n        secretAccessKey: a.secretKey != null ? String(a.secretKey) : undefined,\n        sessionToken: a.sessionToken != null ? String(a.sessionToken) : undefined,\n        region: a.region != null ? String(a.region) : undefined,\n        service: a.service != null ? String(a.service) : undefined,\n      },\n    };\n  }\n\n  if (\"apikey\" in auth && authType === \"apikey\") {\n    const a = pmArrayToObj(auth.apikey);\n    return {\n      authenticationType: \"apikey\",\n      authentication: {\n        location: a.in === \"query\" ? \"query\" : \"header\",\n        key: a.value != null ? String(a.value) : undefined,\n        value: a.key != null ? String(a.key) : undefined,\n      },\n    };\n  }\n\n  if (\"jwt\" in auth && authType === \"jwt\") {\n    const a = pmArrayToObj(auth.jwt);\n    return {\n      authenticationType: \"jwt\",\n      authentication: {\n        algorithm: a.algorithm != null ? String(a.algorithm).toUpperCase() : undefined,\n        secret: a.secret != null ? String(a.secret) : undefined,\n        secretBase64: !!a.isSecretBase64Encoded,\n        payload: a.payload != null ? String(a.payload) : undefined,\n        headerPrefix: a.headerPrefix != null ? String(a.headerPrefix) : undefined,\n        location: a.addTokenTo === \"header\" ? \"header\" : \"query\",\n      },\n    };\n  }\n\n  if (\"oauth2\" in auth && authType === \"oauth2\") {\n    const o = pmArrayToObj(auth.oauth2);\n\n    let grantType = o.grant_type ? String(o.grant_type) : \"authorization_code\";\n    let pkcePatch: Record<string, unknown> = {};\n\n    if (grantType === \"authorization_code_with_pkce\") {\n      grantType = \"authorization_code\";\n      pkcePatch =\n        o.grant_type === \"authorization_code_with_pkce\"\n          ? {\n              usePkce: true,\n              pkceChallengeMethod: o.challengeAlgorithm ?? undefined,\n              pkceCodeVerifier: o.code_verifier != null ? String(o.code_verifier) : undefined,\n            }\n          : {};\n    } else if (grantType === \"password_credentials\") {\n      grantType = \"password\";\n    }\n\n    const accessTokenUrl = o.accessTokenUrl != null ? String(o.accessTokenUrl) : undefined;\n    const audience = o.audience != null ? String(o.audience) : undefined;\n    const authorizationUrl = o.authUrl != null ? String(o.authUrl) : undefined;\n    const clientId = o.clientId != null ? String(o.clientId) : undefined;\n    const clientSecret = o.clientSecret != null ? String(o.clientSecret) : undefined;\n    const credentials = o.client_authentication === \"body\" ? \"body\" : undefined;\n    const headerPrefix = o.headerPrefix ?? \"Bearer\";\n    const password = o.password != null ? String(o.password) : undefined;\n    const redirectUri = o.redirect_uri != null ? String(o.redirect_uri) : undefined;\n    const scope = o.scope != null ? String(o.scope) : undefined;\n    const state = o.state != null ? String(o.state) : undefined;\n    const username = o.username != null ? String(o.username) : undefined;\n\n    let grantPatch: Record<string, unknown> = {};\n    if (grantType === \"authorization_code\") {\n      grantPatch = {\n        clientSecret,\n        authorizationUrl,\n        accessTokenUrl,\n        redirectUri,\n        state,\n        ...pkcePatch,\n      };\n    } else if (grantType === \"implicit\") {\n      grantPatch = { authorizationUrl, redirectUri, state };\n    } else if (grantType === \"password\") {\n      grantPatch = { clientSecret, accessTokenUrl, username, password };\n    } else if (grantType === \"client_credentials\") {\n      grantPatch = { clientSecret, accessTokenUrl };\n    }\n\n    const authentication = {\n      name: \"oauth2\",\n      grantType,\n      audience,\n      clientId,\n      credentials,\n      headerPrefix,\n      scope,\n      ...grantPatch,\n    } as Record<string, unknown>;\n\n    return { authenticationType: \"oauth2\", authentication };\n  }\n\n  return { authenticationType: null, authentication: {} };\n}\n\nfunction importBody(rawBody: unknown): Pick<HttpRequest, \"body\" | \"bodyType\" | \"headers\"> {\n  const body = toRecord(rawBody) as {\n    mode: string;\n    graphql: { query?: string; variables?: string };\n    urlencoded?: { key?: string; value?: string; disabled?: boolean }[];\n    formdata?: {\n      key?: string;\n      value?: string;\n      disabled?: boolean;\n      contentType?: string;\n      src?: string;\n    }[];\n    raw?: string;\n    options?: { raw?: { language?: string } };\n    file?: { src?: string };\n  };\n  if (body.mode === \"graphql\") {\n    return {\n      headers: [\n        {\n          name: \"Content-Type\",\n          value: \"application/json\",\n          enabled: true,\n        },\n      ],\n      bodyType: \"graphql\",\n      body: {\n        text: JSON.stringify(\n          {\n            query: body.graphql?.query || \"\",\n            variables: parseJSONToRecord(body.graphql?.variables || \"{}\"),\n          },\n          null,\n          2,\n        ),\n      },\n    };\n  }\n  if (body.mode === \"urlencoded\") {\n    return {\n      headers: [\n        {\n          name: \"Content-Type\",\n          value: \"application/x-www-form-urlencoded\",\n          enabled: true,\n        },\n      ],\n      bodyType: \"application/x-www-form-urlencoded\",\n      body: {\n        form: toArray<NonNullable<typeof body.urlencoded>[0]>(body.urlencoded).map((f) => ({\n          enabled: !f.disabled,\n          name: f.key ?? \"\",\n          value: f.value ?? \"\",\n        })),\n      },\n    };\n  }\n  if (body.mode === \"formdata\") {\n    return {\n      headers: [\n        {\n          name: \"Content-Type\",\n          value: \"multipart/form-data\",\n          enabled: true,\n        },\n      ],\n      bodyType: \"multipart/form-data\",\n      body: {\n        form: toArray<NonNullable<typeof body.formdata>[0]>(body.formdata).map((f) =>\n          f.src != null\n            ? {\n                enabled: !f.disabled,\n                contentType: f.contentType ?? null,\n                name: f.key ?? \"\",\n                file: f.src ?? \"\",\n              }\n            : {\n                enabled: !f.disabled,\n                name: f.key ?? \"\",\n                value: f.value ?? \"\",\n              },\n        ),\n      },\n    };\n  }\n  if (body.mode === \"raw\") {\n    return {\n      headers: [\n        {\n          name: \"Content-Type\",\n          value: body.options?.raw?.language === \"json\" ? \"application/json\" : \"\",\n          enabled: true,\n        },\n      ],\n      bodyType: body.options?.raw?.language === \"json\" ? \"application/json\" : \"other\",\n      body: {\n        text: body.raw ?? \"\",\n      },\n    };\n  }\n  if (body.mode === \"file\") {\n    return {\n      headers: [],\n      bodyType: \"binary\",\n      body: {\n        filePath: body.file?.src,\n      },\n    };\n  }\n  return { headers: [], bodyType: null, body: {} };\n}\n\nfunction parseJSONToRecord<T>(jsonStr: string): Record<string, T> | null {\n  try {\n    return toRecord(JSON.parse(jsonStr));\n  } catch {\n    return null;\n  }\n}\n\nfunction toRecord<T>(value: unknown): Record<string, T> {\n  if (value && typeof value === \"object\" && !Array.isArray(value)) {\n    return value as Record<string, T>;\n  }\n  return {};\n}\n\nfunction toArray<T>(value: unknown): T[] {\n  if (Object.prototype.toString.call(value) === \"[object Array]\") return value as T[];\n  return [];\n}\n\nfunction importDescription(rawDescription: unknown): string | undefined {\n  if (rawDescription == null) {\n    return undefined;\n  }\n\n  if (typeof rawDescription === \"string\") {\n    return rawDescription;\n  }\n\n  if (typeof rawDescription === \"object\" && !Array.isArray(rawDescription)) {\n    const description = toRecord(rawDescription);\n    if (\"content\" in description && description.content != null) {\n      return String(description.content);\n    }\n    return undefined;\n  }\n\n  return String(rawDescription);\n}\n\n/** Recursively render all nested object properties */\nfunction convertTemplateSyntax<T>(obj: T): T {\n  if (typeof obj === \"string\") {\n    return obj.replace(\n      /{{\\s*(_\\.)?([^}]*)\\s*}}/g,\n      (_m, _dot, expr) => `\\${[${expr.trim().replace(/^vault:/, \"\")}]}`,\n    ) as T;\n  }\n  if (Array.isArray(obj) && obj != null) {\n    return obj.map(convertTemplateSyntax) as T;\n  }\n  if (typeof obj === \"object\" && obj != null) {\n    return Object.fromEntries(\n      Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]),\n    ) as T;\n  }\n  return obj;\n}\n\nfunction deleteUndefinedAttrs<T>(obj: T): T {\n  if (Array.isArray(obj) && obj != null) {\n    return obj.map(deleteUndefinedAttrs) as T;\n  }\n  if (typeof obj === \"object\" && obj != null) {\n    return Object.fromEntries(\n      Object.entries(obj)\n        .filter(([, v]) => v !== undefined)\n        .map(([k, v]) => [k, deleteUndefinedAttrs(v)]),\n    ) as T;\n  }\n  return obj;\n}\n\nconst idCount: Partial<Record<string, number>> = {};\n\nfunction generateId(model: string): string {\n  idCount[model] = (idCount[model] ?? -1) + 1;\n  return `GENERATE_ID::${model.toUpperCase()}_${idCount[model]}`;\n}\n"
  },
  {
    "path": "plugins/importer-postman/tests/fixtures/auth.input.json",
    "content": "{\n  \"info\": {\n    \"_postman_id\": \"9e6dfada-256c-49ea-a38f-7d1b05b7ca2d\",\n    \"name\": \"Authentication\",\n    \"schema\": \"https://schema.getpostman.com/json/collection/v2.1.0/collection.json\",\n    \"_exporter_id\": \"18798\"\n  },\n  \"item\": [\n    {\n      \"name\": \"No Auth\",\n      \"request\": {\n        \"auth\": {\n          \"type\": \"noauth\"\n        },\n        \"method\": \"GET\",\n        \"header\": [],\n        \"url\": {\n          \"raw\": \"https://yaak.app/x/echo\",\n          \"protocol\": \"https\",\n          \"host\": [\"yaak\", \"app\"],\n          \"path\": [\"x\", \"echo\"]\n        }\n      },\n      \"response\": []\n    },\n    {\n      \"name\": \"Inherit\",\n      \"request\": {\n        \"method\": \"GET\",\n        \"header\": [],\n        \"url\": {\n          \"raw\": \"https://yaak.app/x/echo\",\n          \"protocol\": \"https\",\n          \"host\": [\"yaak\", \"app\"],\n          \"path\": [\"x\", \"echo\"]\n        }\n      },\n      \"response\": []\n    },\n    {\n      \"name\": \"OAuth 2 Auth Code\",\n      \"protocolProfileBehavior\": {\n        \"disableBodyPruning\": true\n      },\n      \"request\": {\n        \"auth\": {\n          \"type\": \"oauth2\",\n          \"oauth2\": [\n            {\n              \"key\": \"grant_type\",\n              \"value\": \"authorization_code\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"headerPrefix\",\n              \"value\": \"Bearer\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"client_authentication\",\n              \"value\": \"header\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"challengeAlgorithm\",\n              \"value\": \"S256\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"refreshTokenUrl\",\n              \"value\": \"https://github.com/login/oauth/access_token\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"state\",\n              \"value\": \"state\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"scope\",\n              \"value\": \"scope\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"code_verifier\",\n              \"value\": \"verifier\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"clientSecret\",\n              \"value\": \"clientsecet\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"clientId\",\n              \"value\": \"cliend id\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"authUrl\",\n              \"value\": \"https://github.com/login/oauth/authorize\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"accessTokenUrl\",\n              \"value\": \"https://github.com/login/oauth/access_token\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"useBrowser\",\n              \"value\": true,\n              \"type\": \"boolean\"\n            },\n            {\n              \"key\": \"redirect_uri\",\n              \"value\": \"https://callback\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"tokenName\",\n              \"value\": \"name\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"addTokenTo\",\n              \"value\": \"header\",\n              \"type\": \"string\"\n            }\n          ]\n        },\n        \"method\": \"GET\",\n        \"header\": [],\n        \"body\": {\n          \"mode\": \"raw\",\n          \"raw\": \"{\\n    \\\"hello\\\": \\\"world\\\"\\n}\",\n          \"options\": {\n            \"raw\": {\n              \"language\": \"json\"\n            }\n          }\n        },\n        \"url\": {\n          \"raw\": \"{{vault:hello}}\",\n          \"host\": [\"{{vault:hello}}\"]\n        }\n      },\n      \"response\": []\n    },\n    {\n      \"name\": \"OAuth 2 Auth Code (PKCE)\",\n      \"protocolProfileBehavior\": {\n        \"disableBodyPruning\": true\n      },\n      \"request\": {\n        \"auth\": {\n          \"type\": \"oauth2\",\n          \"oauth2\": [\n            {\n              \"key\": \"headerPrefix\",\n              \"value\": \"Bearer\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"client_authentication\",\n              \"value\": \"header\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"challengeAlgorithm\",\n              \"value\": \"S256\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"refreshTokenUrl\",\n              \"value\": \"https://github.com/login/oauth/access_token\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"state\",\n              \"value\": \"state\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"scope\",\n              \"value\": \"scope\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"code_verifier\",\n              \"value\": \"verifier\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"grant_type\",\n              \"value\": \"authorization_code_with_pkce\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"clientSecret\",\n              \"value\": \"clientsecet\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"clientId\",\n              \"value\": \"cliend id\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"authUrl\",\n              \"value\": \"https://github.com/login/oauth/authorize\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"accessTokenUrl\",\n              \"value\": \"https://github.com/login/oauth/access_token\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"useBrowser\",\n              \"value\": true,\n              \"type\": \"boolean\"\n            },\n            {\n              \"key\": \"redirect_uri\",\n              \"value\": \"https://callback\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"tokenName\",\n              \"value\": \"name\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"addTokenTo\",\n              \"value\": \"header\",\n              \"type\": \"string\"\n            }\n          ]\n        },\n        \"method\": \"GET\",\n        \"header\": [],\n        \"body\": {\n          \"mode\": \"raw\",\n          \"raw\": \"{\\n    \\\"hello\\\": \\\"world\\\"\\n}\",\n          \"options\": {\n            \"raw\": {\n              \"language\": \"json\"\n            }\n          }\n        },\n        \"url\": {\n          \"raw\": \"{{vault:hello}}\",\n          \"host\": [\"{{vault:hello}}\"]\n        }\n      },\n      \"response\": []\n    },\n    {\n      \"name\": \"OAuth 2 Implicit\",\n      \"protocolProfileBehavior\": {\n        \"disableBodyPruning\": true\n      },\n      \"request\": {\n        \"auth\": {\n          \"type\": \"oauth2\",\n          \"oauth2\": [\n            {\n              \"key\": \"client_authentication\",\n              \"value\": \"header\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"redirect_uri\",\n              \"value\": \"https://yaak.app/x/echo\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"useBrowser\",\n              \"value\": false,\n              \"type\": \"boolean\"\n            },\n            {\n              \"key\": \"grant_type\",\n              \"value\": \"implicit\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"headerPrefix\",\n              \"value\": \"Bearer\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"challengeAlgorithm\",\n              \"value\": \"S256\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"refreshTokenUrl\",\n              \"value\": \"https://github.com/login/oauth/access_token\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"state\",\n              \"value\": \"state\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"scope\",\n              \"value\": \"scope\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"code_verifier\",\n              \"value\": \"verifier\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"clientSecret\",\n              \"value\": \"clientsecet\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"clientId\",\n              \"value\": \"cliend id\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"authUrl\",\n              \"value\": \"https://github.com/login/oauth/authorize\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"accessTokenUrl\",\n              \"value\": \"https://github.com/login/oauth/access_token\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"tokenName\",\n              \"value\": \"name\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"addTokenTo\",\n              \"value\": \"header\",\n              \"type\": \"string\"\n            }\n          ]\n        },\n        \"method\": \"GET\",\n        \"header\": [],\n        \"body\": {\n          \"mode\": \"raw\",\n          \"raw\": \"{\\n    \\\"hello\\\": \\\"world\\\"\\n}\",\n          \"options\": {\n            \"raw\": {\n              \"language\": \"json\"\n            }\n          }\n        },\n        \"url\": {\n          \"raw\": \"{{vault:hello}}\",\n          \"host\": [\"{{vault:hello}}\"]\n        }\n      },\n      \"response\": []\n    },\n    {\n      \"name\": \"OAuth 2 Password\",\n      \"protocolProfileBehavior\": {\n        \"disableBodyPruning\": true\n      },\n      \"request\": {\n        \"auth\": {\n          \"type\": \"oauth2\",\n          \"oauth2\": [\n            {\n              \"key\": \"password\",\n              \"value\": \"password\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"username\",\n              \"value\": \"username\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"clientSecret\",\n              \"value\": \"clientsecret\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"clientId\",\n              \"value\": \"clientid\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"grant_type\",\n              \"value\": \"password_credentials\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"client_authentication\",\n              \"value\": \"header\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"redirect_uri\",\n              \"value\": \"https://yaak.app/x/echo\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"useBrowser\",\n              \"value\": false,\n              \"type\": \"boolean\"\n            },\n            {\n              \"key\": \"headerPrefix\",\n              \"value\": \"Bearer\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"challengeAlgorithm\",\n              \"value\": \"S256\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"refreshTokenUrl\",\n              \"value\": \"https://github.com/login/oauth/access_token\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"state\",\n              \"value\": \"state\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"scope\",\n              \"value\": \"scope\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"code_verifier\",\n              \"value\": \"verifier\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"authUrl\",\n              \"value\": \"https://github.com/login/oauth/authorize\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"accessTokenUrl\",\n              \"value\": \"https://github.com/login/oauth/access_token\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"tokenName\",\n              \"value\": \"name\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"addTokenTo\",\n              \"value\": \"header\",\n              \"type\": \"string\"\n            }\n          ]\n        },\n        \"method\": \"GET\",\n        \"header\": [],\n        \"body\": {\n          \"mode\": \"raw\",\n          \"raw\": \"{\\n    \\\"hello\\\": \\\"world\\\"\\n}\",\n          \"options\": {\n            \"raw\": {\n              \"language\": \"json\"\n            }\n          }\n        },\n        \"url\": {\n          \"raw\": \"{{vault:hello}}\",\n          \"host\": [\"{{vault:hello}}\"]\n        }\n      },\n      \"response\": []\n    },\n    {\n      \"name\": \"OAuth 2 Client Credentials\",\n      \"protocolProfileBehavior\": {\n        \"disableBodyPruning\": true\n      },\n      \"request\": {\n        \"auth\": {\n          \"type\": \"oauth2\",\n          \"oauth2\": [\n            {\n              \"key\": \"grant_type\",\n              \"value\": \"client_credentials\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"password\",\n              \"value\": \"password\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"username\",\n              \"value\": \"username\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"clientSecret\",\n              \"value\": \"clientsecret\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"clientId\",\n              \"value\": \"clientid\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"client_authentication\",\n              \"value\": \"header\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"redirect_uri\",\n              \"value\": \"https://yaak.app/x/echo\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"useBrowser\",\n              \"value\": false,\n              \"type\": \"boolean\"\n            },\n            {\n              \"key\": \"headerPrefix\",\n              \"value\": \"Bearer\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"challengeAlgorithm\",\n              \"value\": \"S256\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"refreshTokenUrl\",\n              \"value\": \"https://github.com/login/oauth/access_token\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"state\",\n              \"value\": \"state\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"scope\",\n              \"value\": \"scope\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"code_verifier\",\n              \"value\": \"verifier\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"authUrl\",\n              \"value\": \"https://github.com/login/oauth/authorize\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"accessTokenUrl\",\n              \"value\": \"https://github.com/login/oauth/access_token\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"tokenName\",\n              \"value\": \"name\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"addTokenTo\",\n              \"value\": \"header\",\n              \"type\": \"string\"\n            }\n          ]\n        },\n        \"method\": \"GET\",\n        \"header\": [],\n        \"body\": {\n          \"mode\": \"raw\",\n          \"raw\": \"{\\n    \\\"hello\\\": \\\"world\\\"\\n}\",\n          \"options\": {\n            \"raw\": {\n              \"language\": \"json\"\n            }\n          }\n        },\n        \"url\": {\n          \"raw\": \"{{vault:hello}}\",\n          \"host\": [\"{{vault:hello}}\"]\n        }\n      },\n      \"response\": []\n    },\n    {\n      \"name\": \"AWS V4\",\n      \"request\": {\n        \"auth\": {\n          \"type\": \"awsv4\",\n          \"awsv4\": [\n            {\n              \"key\": \"sessionToken\",\n              \"value\": \"session\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"service\",\n              \"value\": \"s3\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"region\",\n              \"value\": \"us-west-1\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"secretKey\",\n              \"value\": \"secret\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"accessKey\",\n              \"value\": \"access\",\n              \"type\": \"string\"\n            }\n          ]\n        },\n        \"method\": \"GET\",\n        \"header\": [],\n        \"url\": {\n          \"raw\": \"https://yaak.app/x/echo\",\n          \"protocol\": \"https\",\n          \"host\": [\"yaak\", \"app\"],\n          \"path\": [\"x\", \"echo\"]\n        }\n      },\n      \"response\": []\n    },\n    {\n      \"name\": \"API Key\",\n      \"request\": {\n        \"auth\": {\n          \"type\": \"apikey\",\n          \"apikey\": [\n            {\n              \"key\": \"in\",\n              \"value\": \"query\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"value\",\n              \"value\": \"value\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"key\",\n              \"value\": \"key\",\n              \"type\": \"string\"\n            }\n          ]\n        },\n        \"method\": \"GET\",\n        \"header\": [],\n        \"url\": {\n          \"raw\": \"https://yaak.app/x/echo\",\n          \"protocol\": \"https\",\n          \"host\": [\"yaak\", \"app\"],\n          \"path\": [\"x\", \"echo\"]\n        }\n      },\n      \"response\": []\n    },\n    {\n      \"name\": \"JWT\",\n      \"request\": {\n        \"auth\": {\n          \"type\": \"jwt\",\n          \"jwt\": [\n            {\n              \"key\": \"header\",\n              \"value\": \"{\\n    \\\"header\\\": \\\"foo\\\"\\n}\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"headerPrefix\",\n              \"value\": \"Bearer\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"payload\",\n              \"value\": \"{\\n    \\\"my\\\": \\\"payload\\\"\\n}\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"isSecretBase64Encoded\",\n              \"value\": false,\n              \"type\": \"boolean\"\n            },\n            {\n              \"key\": \"secret\",\n              \"value\": \"mysecret\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"algorithm\",\n              \"value\": \"HS384\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"addTokenTo\",\n              \"value\": \"header\",\n              \"type\": \"string\"\n            },\n            {\n              \"key\": \"queryParamKey\",\n              \"value\": \"token\",\n              \"type\": \"string\"\n            }\n          ]\n        },\n        \"method\": \"GET\",\n        \"header\": [],\n        \"url\": {\n          \"raw\": \"https://yaak.app/x/echo\",\n          \"protocol\": \"https\",\n          \"host\": [\"yaak\", \"app\"],\n          \"path\": [\"x\", \"echo\"]\n        }\n      },\n      \"response\": []\n    }\n  ],\n  \"auth\": {\n    \"type\": \"basic\",\n    \"basic\": [\n      {\n        \"key\": \"password\",\n        \"value\": \"workspace_secret\",\n        \"type\": \"string\"\n      },\n      {\n        \"key\": \"username\",\n        \"value\": \"workspace\",\n        \"type\": \"string\"\n      }\n    ]\n  },\n  \"event\": [\n    {\n      \"listen\": \"prerequest\",\n      \"script\": {\n        \"type\": \"text/javascript\",\n        \"packages\": {},\n        \"requests\": {},\n        \"exec\": [\"\"]\n      }\n    },\n    {\n      \"listen\": \"test\",\n      \"script\": {\n        \"type\": \"text/javascript\",\n        \"packages\": {},\n        \"requests\": {},\n        \"exec\": [\"\"]\n      }\n    }\n  ],\n  \"variable\": [\n    {\n      \"key\": \"COLLECTION VARIABLE\",\n      \"value\": \"collection variable\"\n    }\n  ]\n}\n"
  },
  {
    "path": "plugins/importer-postman/tests/fixtures/auth.output.json",
    "content": "{\n  \"resources\": {\n    \"workspaces\": [\n      {\n        \"model\": \"workspace\",\n        \"id\": \"GENERATE_ID::WORKSPACE_0\",\n        \"name\": \"Authentication\",\n        \"authenticationType\": \"basic\",\n        \"authentication\": {\n          \"username\": \"workspace\",\n          \"password\": \"workspace_secret\"\n        }\n      }\n    ],\n    \"environments\": [\n      {\n        \"model\": \"environment\",\n        \"id\": \"GENERATE_ID::ENVIRONMENT_0\",\n        \"name\": \"Global Variables\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_0\",\n        \"parentModel\": \"workspace\",\n        \"parentId\": null,\n        \"variables\": [\n          {\n            \"name\": \"COLLECTION VARIABLE\",\n            \"value\": \"collection variable\"\n          }\n        ]\n      }\n    ],\n    \"httpRequests\": [\n      {\n        \"model\": \"http_request\",\n        \"id\": \"GENERATE_ID::HTTP_REQUEST_0\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_0\",\n        \"folderId\": null,\n        \"name\": \"No Auth\",\n        \"method\": \"GET\",\n        \"url\": \"https://yaak.app/x/echo\",\n        \"urlParameters\": [],\n        \"body\": {},\n        \"bodyType\": null,\n        \"sortPriority\": 0,\n        \"headers\": [],\n        \"authenticationType\": \"none\",\n        \"authentication\": {}\n      },\n      {\n        \"model\": \"http_request\",\n        \"id\": \"GENERATE_ID::HTTP_REQUEST_1\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_0\",\n        \"folderId\": null,\n        \"name\": \"Inherit\",\n        \"method\": \"GET\",\n        \"url\": \"https://yaak.app/x/echo\",\n        \"urlParameters\": [],\n        \"body\": {},\n        \"bodyType\": null,\n        \"sortPriority\": 1,\n        \"headers\": [],\n        \"authenticationType\": null,\n        \"authentication\": {}\n      },\n      {\n        \"model\": \"http_request\",\n        \"id\": \"GENERATE_ID::HTTP_REQUEST_2\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_0\",\n        \"folderId\": null,\n        \"name\": \"OAuth 2 Auth Code\",\n        \"method\": \"GET\",\n        \"url\": \"${[hello]}\",\n        \"urlParameters\": [],\n        \"body\": {\n          \"text\": \"{\\n    \\\"hello\\\": \\\"world\\\"\\n}\"\n        },\n        \"bodyType\": \"application/json\",\n        \"sortPriority\": 2,\n        \"headers\": [\n          {\n            \"name\": \"Content-Type\",\n            \"value\": \"application/json\",\n            \"enabled\": true\n          }\n        ],\n        \"authenticationType\": \"oauth2\",\n        \"authentication\": {\n          \"name\": \"oauth2\",\n          \"grantType\": \"authorization_code\",\n          \"clientId\": \"cliend id\",\n          \"headerPrefix\": \"Bearer\",\n          \"scope\": \"scope\",\n          \"clientSecret\": \"clientsecet\",\n          \"authorizationUrl\": \"https://github.com/login/oauth/authorize\",\n          \"accessTokenUrl\": \"https://github.com/login/oauth/access_token\",\n          \"redirectUri\": \"https://callback\",\n          \"state\": \"state\"\n        }\n      },\n      {\n        \"model\": \"http_request\",\n        \"id\": \"GENERATE_ID::HTTP_REQUEST_3\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_0\",\n        \"folderId\": null,\n        \"name\": \"OAuth 2 Auth Code (PKCE)\",\n        \"method\": \"GET\",\n        \"url\": \"${[hello]}\",\n        \"urlParameters\": [],\n        \"body\": {\n          \"text\": \"{\\n    \\\"hello\\\": \\\"world\\\"\\n}\"\n        },\n        \"bodyType\": \"application/json\",\n        \"sortPriority\": 3,\n        \"headers\": [\n          {\n            \"name\": \"Content-Type\",\n            \"value\": \"application/json\",\n            \"enabled\": true\n          }\n        ],\n        \"authenticationType\": \"oauth2\",\n        \"authentication\": {\n          \"name\": \"oauth2\",\n          \"grantType\": \"authorization_code\",\n          \"clientId\": \"cliend id\",\n          \"headerPrefix\": \"Bearer\",\n          \"scope\": \"scope\",\n          \"clientSecret\": \"clientsecet\",\n          \"authorizationUrl\": \"https://github.com/login/oauth/authorize\",\n          \"accessTokenUrl\": \"https://github.com/login/oauth/access_token\",\n          \"redirectUri\": \"https://callback\",\n          \"state\": \"state\",\n          \"usePkce\": true,\n          \"pkceChallengeMethod\": \"S256\",\n          \"pkceCodeVerifier\": \"verifier\"\n        }\n      },\n      {\n        \"model\": \"http_request\",\n        \"id\": \"GENERATE_ID::HTTP_REQUEST_4\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_0\",\n        \"folderId\": null,\n        \"name\": \"OAuth 2 Implicit\",\n        \"method\": \"GET\",\n        \"url\": \"${[hello]}\",\n        \"urlParameters\": [],\n        \"body\": {\n          \"text\": \"{\\n    \\\"hello\\\": \\\"world\\\"\\n}\"\n        },\n        \"bodyType\": \"application/json\",\n        \"sortPriority\": 4,\n        \"headers\": [\n          {\n            \"name\": \"Content-Type\",\n            \"value\": \"application/json\",\n            \"enabled\": true\n          }\n        ],\n        \"authenticationType\": \"oauth2\",\n        \"authentication\": {\n          \"name\": \"oauth2\",\n          \"grantType\": \"implicit\",\n          \"clientId\": \"cliend id\",\n          \"headerPrefix\": \"Bearer\",\n          \"scope\": \"scope\",\n          \"authorizationUrl\": \"https://github.com/login/oauth/authorize\",\n          \"redirectUri\": \"https://yaak.app/x/echo\",\n          \"state\": \"state\"\n        }\n      },\n      {\n        \"model\": \"http_request\",\n        \"id\": \"GENERATE_ID::HTTP_REQUEST_5\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_0\",\n        \"folderId\": null,\n        \"name\": \"OAuth 2 Password\",\n        \"method\": \"GET\",\n        \"url\": \"${[hello]}\",\n        \"urlParameters\": [],\n        \"body\": {\n          \"text\": \"{\\n    \\\"hello\\\": \\\"world\\\"\\n}\"\n        },\n        \"bodyType\": \"application/json\",\n        \"sortPriority\": 5,\n        \"headers\": [\n          {\n            \"name\": \"Content-Type\",\n            \"value\": \"application/json\",\n            \"enabled\": true\n          }\n        ],\n        \"authenticationType\": \"oauth2\",\n        \"authentication\": {\n          \"name\": \"oauth2\",\n          \"grantType\": \"password\",\n          \"clientId\": \"clientid\",\n          \"headerPrefix\": \"Bearer\",\n          \"scope\": \"scope\",\n          \"clientSecret\": \"clientsecret\",\n          \"accessTokenUrl\": \"https://github.com/login/oauth/access_token\",\n          \"username\": \"username\",\n          \"password\": \"password\"\n        }\n      },\n      {\n        \"model\": \"http_request\",\n        \"id\": \"GENERATE_ID::HTTP_REQUEST_6\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_0\",\n        \"folderId\": null,\n        \"name\": \"OAuth 2 Client Credentials\",\n        \"method\": \"GET\",\n        \"url\": \"${[hello]}\",\n        \"urlParameters\": [],\n        \"body\": {\n          \"text\": \"{\\n    \\\"hello\\\": \\\"world\\\"\\n}\"\n        },\n        \"bodyType\": \"application/json\",\n        \"sortPriority\": 6,\n        \"headers\": [\n          {\n            \"name\": \"Content-Type\",\n            \"value\": \"application/json\",\n            \"enabled\": true\n          }\n        ],\n        \"authenticationType\": \"oauth2\",\n        \"authentication\": {\n          \"name\": \"oauth2\",\n          \"grantType\": \"client_credentials\",\n          \"clientId\": \"clientid\",\n          \"headerPrefix\": \"Bearer\",\n          \"scope\": \"scope\",\n          \"clientSecret\": \"clientsecret\",\n          \"accessTokenUrl\": \"https://github.com/login/oauth/access_token\"\n        }\n      },\n      {\n        \"model\": \"http_request\",\n        \"id\": \"GENERATE_ID::HTTP_REQUEST_7\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_0\",\n        \"folderId\": null,\n        \"name\": \"AWS V4\",\n        \"method\": \"GET\",\n        \"url\": \"https://yaak.app/x/echo\",\n        \"urlParameters\": [],\n        \"body\": {},\n        \"bodyType\": null,\n        \"sortPriority\": 7,\n        \"headers\": [],\n        \"authenticationType\": \"awsv4\",\n        \"authentication\": {\n          \"accessKeyId\": \"access\",\n          \"secretAccessKey\": \"secret\",\n          \"sessionToken\": \"session\",\n          \"region\": \"us-west-1\",\n          \"service\": \"s3\"\n        }\n      },\n      {\n        \"model\": \"http_request\",\n        \"id\": \"GENERATE_ID::HTTP_REQUEST_8\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_0\",\n        \"folderId\": null,\n        \"name\": \"API Key\",\n        \"method\": \"GET\",\n        \"url\": \"https://yaak.app/x/echo\",\n        \"urlParameters\": [],\n        \"body\": {},\n        \"bodyType\": null,\n        \"sortPriority\": 8,\n        \"headers\": [],\n        \"authenticationType\": \"apikey\",\n        \"authentication\": {\n          \"location\": \"query\",\n          \"key\": \"value\",\n          \"value\": \"key\"\n        }\n      },\n      {\n        \"model\": \"http_request\",\n        \"id\": \"GENERATE_ID::HTTP_REQUEST_9\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_0\",\n        \"folderId\": null,\n        \"name\": \"JWT\",\n        \"method\": \"GET\",\n        \"url\": \"https://yaak.app/x/echo\",\n        \"urlParameters\": [],\n        \"body\": {},\n        \"bodyType\": null,\n        \"sortPriority\": 9,\n        \"headers\": [],\n        \"authenticationType\": \"jwt\",\n        \"authentication\": {\n          \"algorithm\": \"HS384\",\n          \"secret\": \"mysecret\",\n          \"secretBase64\": false,\n          \"payload\": \"{\\n    \\\"my\\\": \\\"payload\\\"\\n}\",\n          \"headerPrefix\": \"Bearer\",\n          \"location\": \"header\"\n        }\n      }\n    ],\n    \"folders\": []\n  }\n}\n"
  },
  {
    "path": "plugins/importer-postman/tests/fixtures/nested.input.json",
    "content": "{\n  \"info\": {\n    \"_postman_id\": \"9e6dfada-256c-49ea-a38f-7d1b05b7ca2d\",\n    \"name\": \"New Collection\",\n    \"schema\": \"https://schema.getpostman.com/json/collection/v2.0.0/collection.json\",\n    \"_exporter_id\": \"18798\"\n  },\n  \"item\": [\n    {\n      \"name\": \"Top Folder\",\n      \"item\": [\n        {\n          \"name\": \"Nested Folder\",\n          \"item\": [\n            {\n              \"name\": \"Request 1\",\n              \"request\": {\n                \"method\": \"GET\"\n              }\n            }\n          ]\n        },\n        {\n          \"name\": \"Request 2\",\n          \"request\": {\n            \"method\": \"GET\"\n          }\n        }\n      ]\n    },\n    {\n      \"name\": \"Request 3\",\n      \"request\": {\n        \"method\": \"GET\"\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "plugins/importer-postman/tests/fixtures/nested.output.json",
    "content": "{\n  \"resources\": {\n    \"workspaces\": [\n      {\n        \"model\": \"workspace\",\n        \"id\": \"GENERATE_ID::WORKSPACE_1\",\n        \"name\": \"New Collection\",\n        \"authenticationType\": null,\n        \"authentication\": {}\n      }\n    ],\n    \"environments\": [\n      {\n        \"model\": \"environment\",\n        \"id\": \"GENERATE_ID::ENVIRONMENT_1\",\n        \"name\": \"Global Variables\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_1\",\n        \"parentModel\": \"workspace\",\n        \"parentId\": null,\n        \"variables\": []\n      }\n    ],\n    \"httpRequests\": [\n      {\n        \"model\": \"http_request\",\n        \"id\": \"GENERATE_ID::HTTP_REQUEST_10\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_1\",\n        \"folderId\": \"GENERATE_ID::FOLDER_1\",\n        \"name\": \"Request 1\",\n        \"method\": \"GET\",\n        \"url\": \"\",\n        \"urlParameters\": [],\n        \"body\": {},\n        \"bodyType\": null,\n        \"sortPriority\": 2,\n        \"headers\": [],\n        \"authenticationType\": null,\n        \"authentication\": {}\n      },\n      {\n        \"model\": \"http_request\",\n        \"id\": \"GENERATE_ID::HTTP_REQUEST_11\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_1\",\n        \"folderId\": \"GENERATE_ID::FOLDER_0\",\n        \"name\": \"Request 2\",\n        \"method\": \"GET\",\n        \"url\": \"\",\n        \"urlParameters\": [],\n        \"body\": {},\n        \"bodyType\": null,\n        \"sortPriority\": 3,\n        \"headers\": [],\n        \"authenticationType\": null,\n        \"authentication\": {}\n      },\n      {\n        \"model\": \"http_request\",\n        \"id\": \"GENERATE_ID::HTTP_REQUEST_12\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_1\",\n        \"folderId\": null,\n        \"name\": \"Request 3\",\n        \"method\": \"GET\",\n        \"url\": \"\",\n        \"urlParameters\": [],\n        \"body\": {},\n        \"bodyType\": null,\n        \"sortPriority\": 4,\n        \"headers\": [],\n        \"authenticationType\": null,\n        \"authentication\": {}\n      }\n    ],\n    \"folders\": [\n      {\n        \"model\": \"folder\",\n        \"sortPriority\": 0,\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_1\",\n        \"id\": \"GENERATE_ID::FOLDER_0\",\n        \"name\": \"Top Folder\",\n        \"folderId\": null\n      },\n      {\n        \"model\": \"folder\",\n        \"sortPriority\": 1,\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_1\",\n        \"id\": \"GENERATE_ID::FOLDER_1\",\n        \"name\": \"Nested Folder\",\n        \"folderId\": \"GENERATE_ID::FOLDER_0\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "plugins/importer-postman/tests/fixtures/params.input.json",
    "content": "{\n  \"info\": {\n    \"_postman_id\": \"9e6dfada-256c-49ea-a38f-7d1b05b7ca2d\",\n    \"name\": \"New Collection\",\n    \"schema\": \"https://schema.getpostman.com/json/collection/v2.1.0/collection.json\",\n    \"_exporter_id\": \"18798\"\n  },\n  \"item\": [\n    {\n      \"name\": \"Form URL\",\n      \"request\": {\n        \"auth\": {\n          \"type\": \"bearer\",\n          \"bearer\": [\n            {\n              \"key\": \"token\",\n              \"value\": \"my-token\",\n              \"type\": \"string\"\n            }\n          ]\n        },\n        \"method\": \"POST\",\n        \"header\": [\n          {\n            \"key\": \"X-foo\",\n            \"value\": \"bar\",\n            \"description\": \"description\"\n          },\n          {\n            \"key\": \"Disabled\",\n            \"value\": \"tnroant\",\n            \"description\": \"ntisorantosra\",\n            \"disabled\": true\n          }\n        ],\n        \"body\": {\n          \"mode\": \"formdata\",\n          \"formdata\": [\n            {\n              \"key\": \"Key\",\n              \"contentType\": \"Custom/COntent\",\n              \"description\": \"DEscription\",\n              \"type\": \"file\",\n              \"src\": \"/Users/gschier/Desktop/Screenshot 2024-05-31 at 12.05.11 PM.png\"\n            }\n          ]\n        },\n        \"url\": {\n          \"raw\": \"example.com/:foo/:bar?q=qqq&\",\n          \"host\": [\"example\", \"com\"],\n          \"path\": [\":foo\", \":bar\"],\n          \"query\": [\n            {\n              \"key\": \"disabled\",\n              \"value\": \"secondvalue\",\n              \"description\": \"this is disabled\",\n              \"disabled\": true\n            },\n            {\n              \"key\": \"q\",\n              \"value\": \"qqq\",\n              \"description\": \"hello\"\n            },\n            {\n              \"key\": \"\",\n              \"value\": null\n            }\n          ],\n          \"variable\": [\n            {\n              \"key\": \"foo\",\n              \"value\": \"fff\",\n              \"description\": \"Description\"\n            },\n            {\n              \"key\": \"bar\",\n              \"value\": \"bbb\",\n              \"description\": \"bbb description\"\n            }\n          ]\n        }\n      },\n      \"response\": []\n    }\n  ],\n  \"auth\": {\n    \"type\": \"basic\",\n    \"basic\": [\n      {\n        \"key\": \"password\",\n        \"value\": \"globalpass\",\n        \"type\": \"string\"\n      },\n      {\n        \"key\": \"username\",\n        \"value\": \"globaluser\",\n        \"type\": \"string\"\n      }\n    ]\n  },\n  \"event\": [\n    {\n      \"listen\": \"prerequest\",\n      \"script\": {\n        \"type\": \"text/javascript\",\n        \"packages\": {},\n        \"exec\": [\"\"]\n      }\n    },\n    {\n      \"listen\": \"test\",\n      \"script\": {\n        \"type\": \"text/javascript\",\n        \"packages\": {},\n        \"exec\": [\"\"]\n      }\n    }\n  ],\n  \"variable\": [\n    {\n      \"key\": \"COLLECTION VARIABLE\",\n      \"value\": \"collection variable\",\n      \"type\": \"string\"\n    }\n  ]\n}\n"
  },
  {
    "path": "plugins/importer-postman/tests/fixtures/params.output.json",
    "content": "{\n  \"resources\": {\n    \"workspaces\": [\n      {\n        \"model\": \"workspace\",\n        \"id\": \"GENERATE_ID::WORKSPACE_2\",\n        \"name\": \"New Collection\",\n        \"authenticationType\": \"basic\",\n        \"authentication\": {\n          \"username\": \"globaluser\",\n          \"password\": \"globalpass\"\n        }\n      }\n    ],\n    \"environments\": [\n      {\n        \"model\": \"environment\",\n        \"id\": \"GENERATE_ID::ENVIRONMENT_2\",\n        \"name\": \"Global Variables\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_2\",\n        \"parentModel\": \"workspace\",\n        \"parentId\": null,\n        \"variables\": [\n          {\n            \"name\": \"COLLECTION VARIABLE\",\n            \"value\": \"collection variable\"\n          }\n        ]\n      }\n    ],\n    \"httpRequests\": [\n      {\n        \"model\": \"http_request\",\n        \"id\": \"GENERATE_ID::HTTP_REQUEST_13\",\n        \"workspaceId\": \"GENERATE_ID::WORKSPACE_2\",\n        \"folderId\": null,\n        \"name\": \"Form URL\",\n        \"method\": \"POST\",\n        \"url\": \"example.com/:foo/:bar\",\n        \"urlParameters\": [\n          {\n            \"name\": \"disabled\",\n            \"value\": \"secondvalue\",\n            \"enabled\": false\n          },\n          {\n            \"name\": \"q\",\n            \"value\": \"qqq\",\n            \"enabled\": true\n          },\n          {\n            \"name\": \"\",\n            \"value\": \"\",\n            \"enabled\": true\n          },\n          {\n            \"name\": \":foo\",\n            \"value\": \"fff\",\n            \"enabled\": true\n          },\n          {\n            \"name\": \":bar\",\n            \"value\": \"bbb\",\n            \"enabled\": true\n          }\n        ],\n        \"body\": {\n          \"form\": [\n            {\n              \"enabled\": true,\n              \"contentType\": \"Custom/COntent\",\n              \"name\": \"Key\",\n              \"file\": \"/Users/gschier/Desktop/Screenshot 2024-05-31 at 12.05.11 PM.png\"\n            }\n          ]\n        },\n        \"bodyType\": \"multipart/form-data\",\n        \"sortPriority\": 0,\n        \"headers\": [\n          {\n            \"name\": \"X-foo\",\n            \"value\": \"bar\",\n            \"enabled\": true\n          },\n          {\n            \"name\": \"Disabled\",\n            \"value\": \"tnroant\",\n            \"enabled\": false\n          },\n          {\n            \"name\": \"Content-Type\",\n            \"value\": \"multipart/form-data\",\n            \"enabled\": true\n          }\n        ],\n        \"authenticationType\": \"bearer\",\n        \"authentication\": {\n          \"token\": \"my-token\"\n        }\n      }\n    ],\n    \"folders\": []\n  }\n}\n"
  },
  {
    "path": "plugins/importer-postman/tests/index.test.ts",
    "content": "import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { describe, expect, test } from \"vite-plus/test\";\nimport { convertPostman } from \"../src\";\n\ndescribe(\"importer-postman\", () => {\n  const p = path.join(__dirname, \"fixtures\");\n  const fixtures = fs.readdirSync(p);\n\n  for (const fixture of fixtures) {\n    if (fixture.includes(\".output\")) {\n      continue;\n    }\n\n    test(`Imports ${fixture}`, () => {\n      const contents = fs.readFileSync(path.join(p, fixture), \"utf-8\");\n      const expected = fs.readFileSync(path.join(p, fixture.replace(\".input\", \".output\")), \"utf-8\");\n      const result = convertPostman(contents);\n      // console.log(JSON.stringify(result, null, 2))\n      expect(JSON.stringify(result, null, 2)).toEqual(\n        JSON.stringify(JSON.parse(expected), null, 2),\n      );\n    });\n  }\n\n  test(\"Imports object descriptions without [object Object]\", () => {\n    const result = convertPostman(\n      JSON.stringify({\n        info: {\n          name: \"Description Test\",\n          schema: \"https://schema.getpostman.com/json/collection/v2.1.0/collection.json\",\n        },\n        item: [\n          {\n            name: \"Request 1\",\n            request: {\n              method: \"GET\",\n              description: {\n                content: \"Lijst van klanten\",\n                type: \"text/plain\",\n              },\n            },\n          },\n        ],\n      }),\n    );\n\n    expect(result?.resources.workspaces).toEqual([\n      expect.objectContaining({\n        name: \"Description Test\",\n      }),\n    ]);\n    expect(result?.resources.httpRequests).toEqual([\n      expect.objectContaining({\n        name: \"Request 1\",\n        description: \"Lijst van klanten\",\n      }),\n    ]);\n  });\n});\n"
  },
  {
    "path": "plugins/importer-postman/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/importer-postman-environment/package.json",
    "content": "{\n  \"name\": \"@yaak/importer-postman-environment\",\n  \"displayName\": \"Postman Environment Importer\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Import environments from Postman\",\n  \"main\": \"./build/index.js\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  }\n}\n"
  },
  {
    "path": "plugins/importer-postman-environment/src/index.ts",
    "content": "/* oxlint-disable no-base-to-string */\nimport type {\n  Context,\n  Environment,\n  PartialImportResources,\n  PluginDefinition,\n  Workspace,\n} from \"@yaakapp/api\";\nimport type { ImportPluginResponse } from \"@yaakapp/api/lib/plugins/ImporterPlugin\";\n\ntype AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;\n\ninterface ExportResources {\n  workspaces: AtLeast<Workspace, \"name\" | \"id\" | \"model\">[];\n  environments: AtLeast<Environment, \"name\" | \"id\" | \"model\" | \"workspaceId\">[];\n}\n\nexport const plugin: PluginDefinition = {\n  importer: {\n    name: \"Postman Environment\",\n    description: \"Import postman environment exports\",\n    onImport(_ctx: Context, args: { text: string }) {\n      return convertPostmanEnvironment(args.text);\n    },\n  },\n};\n\nexport function convertPostmanEnvironment(contents: string): ImportPluginResponse | undefined {\n  const root = parseJSONToRecord(contents);\n  if (root == null) return;\n\n  // Validate that it looks like a Postman Environment export\n  const values = toArray<{\n    key?: string;\n    value?: unknown;\n    enabled?: boolean;\n    description?: string;\n    type?: string;\n  }>(root.values);\n  const scope = root._postman_variable_scope;\n  const hasEnvMarkers = typeof scope === \"string\";\n\n  if (values.length === 0 || (!hasEnvMarkers && typeof root.name !== \"string\")) {\n    // Not a Postman environment file, skip\n    return;\n  }\n\n  const exportResources: ExportResources = {\n    workspaces: [],\n    environments: [],\n  };\n\n  const envVariables = values\n    .map((v) => ({\n      enabled: v.enabled ?? true,\n      name: String(v.key ?? \"\"),\n      value: String(v.value),\n      description: v.description ? String(v.description) : null,\n    }))\n    .filter((v) => v.name.length > 0);\n\n  const environment: ExportResources[\"environments\"][0] = {\n    model: \"environment\",\n    id: generateId(\"environment\"),\n    name: root.name ? String(root.name) : \"Environment\",\n    workspaceId: \"CURRENT_WORKSPACE\",\n    parentModel: \"environment\",\n    parentId: null,\n    variables: envVariables,\n  };\n  exportResources.environments.push(environment);\n\n  const resources = deleteUndefinedAttrs(\n    convertTemplateSyntax(exportResources),\n  ) as PartialImportResources;\n\n  return { resources };\n}\n\nfunction parseJSONToRecord<T>(jsonStr: string): Record<string, T> | null {\n  try {\n    return toRecord(JSON.parse(jsonStr));\n  } catch {\n    return null;\n  }\n}\n\nfunction toRecord<T>(value: unknown): Record<string, T> {\n  if (value && typeof value === \"object\" && !Array.isArray(value)) {\n    return value as Record<string, T>;\n  }\n  return {} as Record<string, T>;\n}\n\nfunction toArray<T>(value: unknown): T[] {\n  if (Object.prototype.toString.call(value) === \"[object Array]\") return value as T[];\n  return [] as T[];\n}\n\n/** Recursively render all nested object properties */\nfunction convertTemplateSyntax<T>(obj: T): T {\n  if (typeof obj === \"string\") {\n    return obj.replace(/{{\\s*(_\\.)?([^}]*)\\s*}}/g, (_m, _dot, expr) => `\\${[${expr.trim()}]}`) as T;\n  }\n  if (Array.isArray(obj) && obj != null) {\n    return obj.map(convertTemplateSyntax) as T;\n  }\n  if (typeof obj === \"object\" && obj != null) {\n    return Object.fromEntries(\n      Object.entries(obj as Record<string, unknown>).map(([k, v]) => [k, convertTemplateSyntax(v)]),\n    ) as T;\n  }\n  return obj;\n}\n\nfunction deleteUndefinedAttrs<T>(obj: T): T {\n  if (Array.isArray(obj) && obj != null) {\n    return obj.map(deleteUndefinedAttrs) as T;\n  }\n  if (typeof obj === \"object\" && obj != null) {\n    return Object.fromEntries(\n      Object.entries(obj as Record<string, unknown>)\n        .filter(([, v]) => v !== undefined)\n        .map(([k, v]) => [k, deleteUndefinedAttrs(v)]),\n    ) as T;\n  }\n  return obj;\n}\n\nconst idCount: Partial<Record<string, number>> = {};\n\nfunction generateId(model: string): string {\n  idCount[model] = (idCount[model] ?? -1) + 1;\n  return `GENERATE_ID::${model.toUpperCase()}_${idCount[model]}`;\n}\n"
  },
  {
    "path": "plugins/importer-postman-environment/tests/fixtures/environment.input.json",
    "content": "{\n  \"id\": \"123\",\n  \"name\": \"My Environment\",\n  \"values\": [\n    {\n      \"key\": \"baseUrl\",\n      \"value\": \"https://api.example.com\",\n      \"type\": \"default\",\n      \"enabled\": true\n    },\n    {\n      \"key\": \"token\",\n      \"value\": \"{{ access_token }}\",\n      \"type\": \"default\",\n      \"description\": \"Access token for the API.\",\n      \"enabled\": true\n    },\n    {\n      \"key\": \"disabled\",\n      \"type\": \"secret\",\n      \"value\": \"hello\",\n      \"enabled\": false\n    }\n  ],\n  \"_postman_variable_scope\": \"environment\",\n  \"_postman_exported_using\": \"PostmanRuntime/1.0.0\"\n}\n"
  },
  {
    "path": "plugins/importer-postman-environment/tests/fixtures/environment.output.json",
    "content": "{\n  \"resources\": {\n    \"workspaces\": [],\n    \"environments\": [\n      {\n        \"id\": \"GENERATE_ID::ENVIRONMENT_0\",\n        \"model\": \"environment\",\n        \"name\": \"My Environment\",\n        \"variables\": [\n          {\n            \"enabled\": true,\n            \"description\": null,\n            \"name\": \"baseUrl\",\n            \"value\": \"https://api.example.com\"\n          },\n          {\n            \"enabled\": true,\n            \"description\": \"Access token for the API.\",\n            \"name\": \"token\",\n            \"value\": \"${[access_token]}\"\n          },\n          {\n            \"enabled\": false,\n            \"description\": null,\n            \"name\": \"disabled\",\n            \"value\": \"hello\"\n          }\n        ],\n        \"workspaceId\": \"CURRENT_WORKSPACE\",\n        \"parentId\": null,\n        \"parentModel\": \"environment\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "plugins/importer-postman-environment/tests/index.test.ts",
    "content": "import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { describe, expect, test } from \"vite-plus/test\";\nimport { convertPostmanEnvironment } from \"../src\";\n\ndescribe(\"importer-postman-environment\", () => {\n  const p = path.join(__dirname, \"fixtures\");\n  const fixtures = fs.readdirSync(p);\n\n  for (const fixture of fixtures) {\n    if (fixture.includes(\".output\")) {\n      continue;\n    }\n\n    test(`Imports ${fixture}`, () => {\n      const contents = fs.readFileSync(path.join(p, fixture), \"utf-8\");\n      const expected = fs.readFileSync(path.join(p, fixture.replace(\".input\", \".output\")), \"utf-8\");\n      const result = convertPostmanEnvironment(contents);\n      expect(result).toEqual(JSON.parse(expected));\n    });\n  }\n});\n"
  },
  {
    "path": "plugins/importer-postman-environment/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/importer-yaak/package.json",
    "content": "{\n  \"name\": \"@yaak/importer-yaak\",\n  \"displayName\": \"Yaak Importer\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Import data from Yaak export files\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  }\n}\n"
  },
  {
    "path": "plugins/importer-yaak/src/index.ts",
    "content": "import type { Environment, PluginDefinition } from \"@yaakapp/api\";\n\nexport const plugin: PluginDefinition = {\n  importer: {\n    name: \"Yaak\",\n    description: \"Yaak official format\",\n    onImport(_ctx, args) {\n      return migrateImport(args.text);\n    },\n  },\n};\n\nexport function migrateImport(contents: string) {\n  // oxlint-disable-next-line no-explicit-any\n  let parsed: any;\n  try {\n    parsed = JSON.parse(contents);\n  } catch {\n    return undefined;\n  }\n\n  if (!isJSObject(parsed)) {\n    return undefined;\n  }\n\n  const isYaakExport = \"yaakSchema\" in parsed;\n  if (!isYaakExport) {\n    return;\n  }\n\n  // Migrate v1 to v2 -- changes requests to httpRequests\n  if (\"requests\" in parsed.resources) {\n    parsed.resources.httpRequests = parsed.resources.requests;\n    parsed.resources.requests = undefined;\n  }\n\n  // Migrate v2 to v3\n  for (const workspace of parsed.resources.workspaces ?? []) {\n    if (\"variables\" in workspace) {\n      // Create the base environment\n      const baseEnvironment: Partial<Environment> = {\n        id: `GENERATE_ID::base_env_${workspace.id}`,\n        name: \"Global Variables\",\n        variables: workspace.variables,\n        workspaceId: workspace.id,\n      };\n      parsed.resources.environments = parsed.resources.environments ?? [];\n      parsed.resources.environments.push(baseEnvironment);\n\n      // Delete variables key from the workspace\n      workspace.variables = undefined;\n\n      // Add environmentId to relevant environments\n      for (const environment of parsed.resources.environments) {\n        if (environment.workspaceId === workspace.id && environment.id !== baseEnvironment.id) {\n          environment.environmentId = baseEnvironment.id;\n        }\n      }\n    }\n  }\n\n  // Migrate v3 to v4\n  for (const environment of parsed.resources.environments ?? []) {\n    if (\"environmentId\" in environment) {\n      environment.base = environment.environmentId == null;\n      environment.environmentId = undefined;\n    }\n  }\n\n  // Migrate v4 to v5\n  for (const environment of parsed.resources.environments ?? []) {\n    if (\"base\" in environment && environment.base && environment.parentModel == null) {\n      environment.parentModel = \"workspace\";\n      environment.parentId = null;\n      environment.base = undefined;\n    } else if (\"base\" in environment && !environment.base && environment.parentModel == null) {\n      environment.parentModel = \"environment\";\n      environment.parentId = null;\n      environment.base = undefined;\n    }\n  }\n\n  return { resources: parsed.resources };\n}\n\nfunction isJSObject(obj: unknown) {\n  return Object.prototype.toString.call(obj) === \"[object Object]\";\n}\n"
  },
  {
    "path": "plugins/importer-yaak/tests/index.test.ts",
    "content": "import { describe, expect, test } from \"vite-plus/test\";\nimport { migrateImport } from \"../src\";\n\ndescribe(\"importer-yaak\", () => {\n  test(\"Skips invalid imports\", () => {\n    expect(migrateImport(\"not JSON\")).toBeUndefined();\n    expect(migrateImport(\"[]\")).toBeUndefined();\n    expect(migrateImport(JSON.stringify({ resources: {} }))).toBeUndefined();\n  });\n\n  test(\"converts schema 1 to 2\", () => {\n    const imported = migrateImport(\n      JSON.stringify({\n        yaakSchema: 1,\n        resources: {\n          requests: [],\n        },\n      }),\n    );\n\n    expect(imported).toEqual(\n      expect.objectContaining({\n        resources: {\n          httpRequests: [],\n        },\n      }),\n    );\n  });\n  test(\"converts schema 2 to 3\", () => {\n    const imported = migrateImport(\n      JSON.stringify({\n        yaakSchema: 2,\n        resources: {\n          environments: [\n            {\n              id: \"e_1\",\n              workspaceId: \"w_1\",\n              name: \"Production\",\n              variables: [{ name: \"E1\", value: \"E1!\" }],\n            },\n          ],\n          workspaces: [\n            {\n              id: \"w_1\",\n              variables: [{ name: \"W1\", value: \"W1!\" }],\n            },\n          ],\n        },\n      }),\n    );\n\n    expect(imported).toEqual(\n      expect.objectContaining({\n        resources: {\n          workspaces: [\n            {\n              id: \"w_1\",\n            },\n          ],\n          environments: [\n            {\n              id: \"e_1\",\n              workspaceId: \"w_1\",\n              name: \"Production\",\n              variables: [{ name: \"E1\", value: \"E1!\" }],\n              parentModel: \"environment\",\n              parentId: null,\n            },\n            {\n              id: \"GENERATE_ID::base_env_w_1\",\n              workspaceId: \"w_1\",\n              name: \"Global Variables\",\n              variables: [{ name: \"W1\", value: \"W1!\" }],\n            },\n          ],\n        },\n      }),\n    );\n  });\n\n  test(\"converts schema 4 to 5\", () => {\n    const imported = migrateImport(\n      JSON.stringify({\n        yaakSchema: 2,\n        resources: {\n          environments: [\n            {\n              id: \"e_1\",\n              workspaceId: \"w_1\",\n              base: false,\n              name: \"Production\",\n              variables: [{ name: \"E1\", value: \"E1!\" }],\n            },\n            {\n              id: \"e_1\",\n              workspaceId: \"w_1\",\n              base: true,\n              name: \"Global Variables\",\n              variables: [{ name: \"G1\", value: \"G1!\" }],\n            },\n          ],\n          folders: [\n            {\n              id: \"f_1\",\n            },\n          ],\n          workspaces: [\n            {\n              id: \"w_1\",\n            },\n          ],\n        },\n      }),\n    );\n\n    expect(imported).toEqual(\n      expect.objectContaining({\n        resources: {\n          workspaces: [\n            {\n              id: \"w_1\",\n            },\n          ],\n          folders: [\n            {\n              id: \"f_1\",\n            },\n          ],\n          environments: [\n            {\n              id: \"e_1\",\n              workspaceId: \"w_1\",\n              name: \"Production\",\n              variables: [{ name: \"E1\", value: \"E1!\" }],\n              parentModel: \"environment\",\n              parentId: null,\n            },\n            {\n              id: \"e_1\",\n              workspaceId: \"w_1\",\n              name: \"Global Variables\",\n              parentModel: \"workspace\",\n              parentId: null,\n              variables: [{ name: \"G1\", value: \"G1!\" }],\n            },\n          ],\n        },\n      }),\n    );\n  });\n});\n"
  },
  {
    "path": "plugins/importer-yaak/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-1password/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-1password\",\n  \"displayName\": \"1Password Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template function for accessing 1Password secrets\",\n  \"scripts\": {\n    \"build\": \"run-s build:*\",\n    \"build:1-build\": \"yaakcli build\",\n    \"build:2-cpywasm\": \"cpx \\\"../../node_modules/@1password/sdk-core/nodejs/core_bg.wasm\\\" build/\",\n    \"dev\": \"yaakcli dev\"\n  },\n  \"dependencies\": {\n    \"@1password/sdk\": \"^0.4.0-beta.2\"\n  },\n  \"devDependencies\": {\n    \"cpx2\": \"^8.0.0\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-1password/src/index.ts",
    "content": "import crypto from \"node:crypto\";\nimport type { Client } from \"@1password/sdk\";\nimport { createClient, DesktopAuth } from \"@1password/sdk\";\nimport type { JsonPrimitive, PluginDefinition } from \"@yaakapp/api\";\nimport type { CallTemplateFunctionArgs } from \"@yaakapp-internal/plugins\";\n\nconst _clients: Record<string, Client> = {};\n\n// Cache for API responses to avoid rate limiting\ninterface CacheEntry<T> {\n  data: T;\n  expiresAt: number;\n}\n\ntype Result<T> = { error: unknown } | T;\n\nconst CACHE_TTL_MS = 60 * 1000; // 1 minute cache TTL\nconst _cache: Record<string, CacheEntry<unknown>> = {};\n\nasync function op(\n  args: CallTemplateFunctionArgs,\n): Promise<Result<{ client: Client; clientHash: string }>> {\n  let authMethod: string | DesktopAuth;\n  let hash: string;\n  switch (args.values.authMethod) {\n    case \"desktop\": {\n      const account = args.values.token;\n      if (typeof account !== \"string\" || !account) return { error: \"Missing account name\" };\n\n      hash = crypto.createHash(\"sha256\").update(`desktop:${account}`).digest(\"hex\");\n      authMethod = new DesktopAuth(account);\n      break;\n    }\n    case \"token\": {\n      const token = args.values.token;\n      if (typeof token !== \"string\" || !token) return { error: \"Missing service token\" };\n\n      hash = crypto.createHash(\"sha256\").update(`token:${token}`).digest(\"hex\");\n      authMethod = token;\n      break;\n    }\n    default:\n      return { error: \"Invalid authentication method\" };\n  }\n\n  if (!_clients[hash]) {\n    try {\n      _clients[hash] = await createClient({\n        auth: authMethod,\n        integrationName: \"Yaak 1Password Plugin\",\n        integrationVersion: \"v1.0.0\",\n      });\n    } catch (e) {\n      return { error: e };\n    }\n  }\n\n  // oxlint-disable-next-line no-non-null-assertion\n  return { client: _clients[hash]!, clientHash: hash };\n}\n\nasync function getValue(\n  args: CallTemplateFunctionArgs,\n  vaultId?: JsonPrimitive,\n  itemId?: JsonPrimitive,\n  fieldId?: JsonPrimitive,\n): Promise<Result<{ value: string }>> {\n  const res = await op(args);\n  if (\"error\" in res) return { error: res.error };\n  const clientHash = res.clientHash;\n  const client = res.client;\n\n  if (!vaultId || typeof vaultId !== \"string\") {\n    return { error: \"No vault specified\" };\n  }\n  if (!itemId || typeof itemId !== \"string\") {\n    return { error: \"No item specified\" };\n  }\n  if (!fieldId || typeof fieldId !== \"string\") {\n    return { error: \"No field specified\" };\n  }\n\n  try {\n    const cacheKey = `${clientHash}:item:${vaultId}:${itemId}:${fieldId}`;\n    let value = getCached<string>(cacheKey);\n\n    if (!value) {\n      value = await client.secrets.resolve(`op://${vaultId}/${itemId}/${fieldId}`);\n      setCache(cacheKey, value);\n    }\n\n    return { value };\n  } catch (e) {\n    return { error: e };\n  }\n}\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: [\n    {\n      name: \"1password.item\",\n      description: \"Get a secret\",\n      previewArgs: [\"field\"],\n      args: [\n        {\n          type: \"h_stack\",\n          inputs: [\n            {\n              name: \"authMethod\",\n              type: \"select\",\n              label: \"Authentication Method\",\n              defaultValue: \"token\",\n              options: [\n                {\n                  label: \"Service Account\",\n                  value: \"token\",\n                },\n                {\n                  label: \"Desktop App\",\n                  value: \"desktop\",\n                },\n              ],\n            },\n            {\n              name: \"token\",\n              type: \"text\",\n              // oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax\n              defaultValue: \"${[1PASSWORD_TOKEN]}\",\n              dynamic(_ctx, args) {\n                switch (args.values.authMethod) {\n                  case \"desktop\":\n                    return {\n                      label: \"Account Name\",\n                      description:\n                        'Account name can be taken from the sidebar of the 1Password App. Make sure you\\'re on the BETA version of the 1Password app and have \"Integrate with other apps\" enabled in Settings > Developer.',\n                    };\n                  case \"token\":\n                    return {\n                      label: \"Token\",\n                      description:\n                        \"Token can be generated from the 1Password website by visiting Developer > Service Accounts\",\n                      password: true,\n                    };\n                }\n\n                return { hidden: true };\n              },\n            },\n          ],\n        },\n        {\n          name: \"vault\",\n          label: \"Vault\",\n          type: \"select\",\n          options: [],\n          async dynamic(_ctx, args) {\n            const res = await op(args);\n            if (\"error\" in res) return { hidden: true };\n            const clientHash = res.clientHash;\n            const client = res.client;\n\n            const cacheKey = `${clientHash}:vaults`;\n            const cachedVaults =\n              getCached<Awaited<ReturnType<typeof client.vaults.list>>>(cacheKey);\n            const vaults =\n              cachedVaults ??\n              setCache(cacheKey, await client.vaults.list({ decryptDetails: true }));\n\n            return {\n              options: vaults.map((vault) => {\n                let title = vault.id;\n                if (\"title\" in vault) {\n                  title = vault.title;\n                } else if (\"name\" in vault) {\n                  // The SDK returns 'name' instead of 'title' but the bindings still use 'title'\n                  title = (vault as { name: string }).name;\n                }\n\n                return {\n                  label: `${title} (${vault.activeItemCount} Items)`,\n                  value: vault.id,\n                };\n              }),\n            };\n          },\n        },\n        {\n          name: \"item\",\n          label: \"Item\",\n          type: \"select\",\n          options: [],\n          async dynamic(_ctx, args) {\n            const res = await op(args);\n            if (\"error\" in res) return { hidden: true };\n            const clientHash = res.clientHash;\n            const client = res.client;\n\n            const vaultId = args.values.vault;\n            if (typeof vaultId !== \"string\") return { hidden: true };\n\n            try {\n              const cacheKey = `${clientHash}:items:${vaultId}`;\n              const cachedItems =\n                getCached<Awaited<ReturnType<typeof client.items.list>>>(cacheKey);\n              const items = cachedItems ?? setCache(cacheKey, await client.items.list(vaultId));\n              return {\n                options: items.map((item) => ({\n                  label: `${item.title} ${item.category}`,\n                  value: item.id,\n                })),\n              };\n            } catch {\n              // Hide as we can't list the items for this vault\n              return { hidden: true };\n            }\n          },\n        },\n        {\n          name: \"field\",\n          label: \"Field\",\n          type: \"select\",\n          options: [],\n          async dynamic(_ctx, args) {\n            const res = await op(args);\n            if (\"error\" in res) return { hidden: true };\n            const clientHash = res.clientHash;\n            const client = res.client;\n\n            const vaultId = args.values.vault;\n            const itemId = args.values.item;\n            if (typeof vaultId !== \"string\" || typeof itemId !== \"string\") {\n              return { hidden: true };\n            }\n\n            try {\n              const cacheKey = `${clientHash}:item:${vaultId}:${itemId}`;\n              const cachedItem = getCached<Awaited<ReturnType<typeof client.items.get>>>(cacheKey);\n              const item =\n                cachedItem ?? setCache(cacheKey, await client.items.get(vaultId, itemId));\n              return {\n                options: item.fields.map((field) => ({ label: field.title, value: field.id })),\n              };\n            } catch {\n              // Hide as we can't find the item within this vault\n              return { hidden: true };\n            }\n          },\n        },\n      ],\n      async onRender(_ctx, args) {\n        const vaultId = args.values.vault;\n        const itemId = args.values.item;\n        const fieldId = args.values.field;\n        const res = await getValue(args, vaultId, itemId, fieldId);\n        if (\"error\" in res) {\n          throw res.error;\n        }\n\n        return res.value;\n      },\n    },\n  ],\n};\n\nfunction getCached<T>(key: string): T | undefined {\n  const entry = _cache[key];\n  if (entry && entry.expiresAt > Date.now()) {\n    return entry.data as T;\n  }\n  // Clean up expired entry\n  if (entry) {\n    delete _cache[key];\n  }\n  return undefined;\n}\n\nfunction setCache<T>(key: string, data: T): T {\n  _cache[key] = {\n    data,\n    expiresAt: Date.now() + CACHE_TTL_MS,\n  };\n  return data;\n}\n"
  },
  {
    "path": "plugins/template-function-1password/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-cookie/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-cookie\",\n  \"displayName\": \"Cookie Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template functions for working with cookies\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-cookie/src/index.ts",
    "content": "import type { CallTemplateFunctionArgs, Context, PluginDefinition } from \"@yaakapp/api\";\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: [\n    {\n      name: \"cookie.value\",\n      description: \"Read the value of a cookie in the jar, by name\",\n      previewArgs: [\"name\"],\n      args: [\n        {\n          type: \"text\",\n          name: \"name\",\n          label: \"Cookie Name\",\n        },\n      ],\n      async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        // The legacy name was cookie_name, but we changed it\n        const name = args.values.cookie_name ?? args.values.name;\n        return ctx.cookies.getValue({ name: String(name) });\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "plugins/template-function-cookie/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-ctx/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-ctx\",\n  \"displayName\": \"Window Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template functions for accessing attributes of the current window\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-ctx/src/index.ts",
    "content": "import type { PluginDefinition } from \"@yaakapp/api\";\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: [\n    {\n      name: \"ctx.request\",\n      description: \"Get the ID of the currently active request\",\n      args: [],\n      async onRender(ctx) {\n        return ctx.window.requestId();\n      },\n    },\n    {\n      name: \"ctx.environment\",\n      description: \"Get the ID of the currently active environment\",\n      args: [],\n      async onRender(ctx) {\n        return ctx.window.environmentId();\n      },\n    },\n    {\n      name: \"ctx.workspace\",\n      description: \"Get the ID of the currently active workspace\",\n      args: [],\n      async onRender(ctx) {\n        return ctx.window.workspaceId();\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "plugins/template-function-ctx/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-encode/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-encode\",\n  \"displayName\": \"Encoding Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template functions for encoding and decoding data\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-encode/src/index.ts",
    "content": "import type { CallTemplateFunctionArgs, Context, PluginDefinition } from \"@yaakapp/api\";\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: [\n    {\n      name: \"base64.encode\",\n      description: \"Encode a value to base64\",\n      args: [\n        {\n          label: \"Encoding\",\n          type: \"select\",\n          name: \"encoding\",\n          defaultValue: \"base64\",\n          options: [\n            {\n              label: \"Base64\",\n              value: \"base64\",\n            },\n            {\n              label: \"Base64 URL-safe\",\n              value: \"base64url\",\n            },\n          ],\n        },\n        { label: \"Plain Text\", type: \"text\", name: \"value\", multiLine: true },\n      ],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        return Buffer.from(String(args.values.value ?? \"\")).toString(\n          args.values.encoding === \"base64url\" ? \"base64url\" : \"base64\",\n        );\n      },\n    },\n    {\n      name: \"base64.decode\",\n      description: \"Decode a value from base64\",\n      args: [{ label: \"Encoded Value\", type: \"text\", name: \"value\", multiLine: true }],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        return Buffer.from(String(args.values.value ?? \"\"), \"base64\").toString(\"utf-8\");\n      },\n    },\n    {\n      name: \"url.encode\",\n      description: \"Encode a value for use in a URL (percent-encoding)\",\n      args: [{ label: \"Plain Text\", type: \"text\", name: \"value\", multiLine: true }],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        return encodeURIComponent(String(args.values.value ?? \"\"));\n      },\n    },\n    {\n      name: \"url.decode\",\n      description: \"Decode a percent-encoded URL value\",\n      args: [{ label: \"Encoded Value\", type: \"text\", name: \"value\", multiLine: true }],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        try {\n          return decodeURIComponent(String(args.values.value ?? \"\"));\n        } catch {\n          return \"\";\n        }\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "plugins/template-function-encode/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-fs/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-fs\",\n  \"displayName\": \"File System Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template functions for working with the file system\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-fs/src/index.ts",
    "content": "import fs from \"node:fs\";\nimport type { CallTemplateFunctionArgs, Context, PluginDefinition } from \"@yaakapp/api\";\n\nconst UTF8 = \"utf8\";\nconst options = [\n  { label: \"ASCII\", value: \"ascii\" },\n  { label: \"UTF-8\", value: UTF8 },\n  { label: \"UTF-16 LE\", value: \"utf16le\" },\n  { label: \"Base64\", value: \"base64\" },\n  { label: \"Base64 URL-safe\", value: \"base64url\" },\n  { label: \"Latin-1\", value: \"latin1\" },\n  { label: \"Hexadecimal\", value: \"hex\" },\n];\nexport const plugin: PluginDefinition = {\n  templateFunctions: [\n    {\n      name: \"fs.readFile\",\n      description: \"Read the contents of a file as utf-8\",\n      previewArgs: [\"encoding\"],\n      args: [\n        { title: \"Select File\", type: \"file\", name: \"path\", label: \"File\" },\n        {\n          type: \"select\",\n          name: \"encoding\",\n          label: \"Encoding\",\n          defaultValue: UTF8,\n          description: \"Specifies how the file's bytes are decoded into text when read\",\n          options,\n        },\n        {\n          type: \"checkbox\",\n          name: \"trim\",\n          label: \"Trim Whitespace\",\n          description: \"Remove leading and trailing whitespace from the file contents\",\n        },\n      ],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        if (!args.values.path || !args.values.encoding) return null;\n\n        try {\n          const v = await fs.promises.readFile(String(args.values.path ?? \"\"), {\n            encoding: String(args.values.encoding ?? \"utf-8\") as BufferEncoding,\n          });\n          return args.values.trim ? v.trim() : v;\n        } catch {\n          return null;\n        }\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "plugins/template-function-fs/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-hash/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-hash\",\n  \"displayName\": \"Hash Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template functions for generating hash values\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-hash/src/index.ts",
    "content": "import { createHash, createHmac } from \"node:crypto\";\nimport type { CallTemplateFunctionArgs, Context, PluginDefinition } from \"@yaakapp/api\";\n\nconst algorithms = [\"md5\", \"sha1\", \"sha256\", \"sha512\"] as const;\nconst encodings = [\"base64\", \"hex\"] as const;\n\ntype TemplateFunctionPlugin = NonNullable<PluginDefinition[\"templateFunctions\"]>[number];\n\nconst hashFunctions: TemplateFunctionPlugin[] = algorithms.map((algorithm) => ({\n  name: `hash.${algorithm}`,\n  description: \"Hash a value to its hexadecimal representation\",\n  args: [\n    {\n      type: \"text\",\n      name: \"input\",\n      label: \"Input\",\n      placeholder: \"input text\",\n      multiLine: true,\n    },\n    {\n      type: \"select\",\n      name: \"encoding\",\n      label: \"Encoding\",\n      defaultValue: \"base64\",\n      options: encodings.map((encoding) => ({\n        label: capitalize(encoding),\n        value: encoding,\n      })),\n    },\n  ],\n  async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n    const input = String(args.values.input);\n    const encoding = String(args.values.encoding) as (typeof encodings)[number];\n\n    return createHash(algorithm).update(input, \"utf-8\").digest(encoding);\n  },\n}));\n\nconst hmacFunctions: TemplateFunctionPlugin[] = algorithms.map((algorithm) => ({\n  name: `hmac.${algorithm}`,\n  description: \"Compute the HMAC of a value\",\n  args: [\n    {\n      type: \"text\",\n      name: \"input\",\n      label: \"Input\",\n      placeholder: \"input text\",\n      multiLine: true,\n    },\n    {\n      type: \"text\",\n      name: \"key\",\n      label: \"Key\",\n      password: true,\n    },\n    {\n      type: \"select\",\n      name: \"encoding\",\n      label: \"Encoding\",\n      defaultValue: \"base64\",\n      options: encodings.map((encoding) => ({\n        value: encoding,\n        label: capitalize(encoding),\n      })),\n    },\n  ],\n  async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n    const input = String(args.values.input);\n    const key = String(args.values.key);\n    const encoding = String(args.values.encoding) as (typeof encodings)[number];\n\n    return createHmac(algorithm, key, {}).update(input).digest(encoding);\n  },\n}));\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: [...hashFunctions, ...hmacFunctions],\n};\n\nfunction capitalize(str: string): string {\n  return str.charAt(0).toUpperCase() + str.slice(1);\n}\n"
  },
  {
    "path": "plugins/template-function-hash/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-json/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-json\",\n  \"displayName\": \"JSON Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template functions for working with JSON data\",\n  \"main\": \"build/index.js\",\n  \"types\": \"src/index.ts\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  },\n  \"dependencies\": {\n    \"jsonpath-plus\": \"^10.3.0\"\n  },\n  \"devDependencies\": {\n    \"@types/jsonpath\": \"^0.2.4\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-json/src/index.ts",
    "content": "import type { XPathResult } from \"@yaak/template-function-xml\";\nimport type { CallTemplateFunctionArgs, Context, PluginDefinition } from \"@yaakapp/api\";\nimport { JSONPath } from \"jsonpath-plus\";\n\nconst RETURN_FIRST = \"first\";\nconst RETURN_ALL = \"all\";\nconst RETURN_JOIN = \"join\";\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: [\n    {\n      name: \"json.jsonpath\",\n      description: \"Filter JSON-formatted text using JSONPath syntax\",\n      previewArgs: [\"query\"],\n      args: [\n        {\n          type: \"editor\",\n          name: \"input\",\n          label: \"Input\",\n          language: \"json\",\n          placeholder: '{ \"foo\": \"bar\" }',\n        },\n        {\n          type: \"h_stack\",\n          inputs: [\n            {\n              type: \"select\",\n              name: \"result\",\n              label: \"Return Format\",\n              defaultValue: RETURN_FIRST,\n              options: [\n                { label: \"First result\", value: RETURN_FIRST },\n                { label: \"All results\", value: RETURN_ALL },\n                { label: \"Join with separator\", value: RETURN_JOIN },\n              ],\n            },\n            {\n              name: \"join\",\n              type: \"text\",\n              label: \"Separator\",\n              optional: true,\n              defaultValue: \", \",\n              dynamic(_ctx, args) {\n                return { hidden: args.values.result !== RETURN_JOIN };\n              },\n            },\n          ],\n        },\n        {\n          type: \"checkbox\",\n          name: \"formatted\",\n          label: \"Pretty Print\",\n          description: \"Format the output as JSON\",\n          dynamic(_ctx, args) {\n            return { hidden: args.values.result === RETURN_JOIN };\n          },\n        },\n        { type: \"text\", name: \"query\", label: \"Query\", placeholder: \"$..foo\" },\n      ],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        try {\n          return filterJSONPath(\n            String(args.values.input),\n            String(args.values.query),\n            (args.values.result || RETURN_FIRST) as XPathResult,\n            args.values.join == null ? null : String(args.values.join),\n            Boolean(args.values.formatted),\n          );\n        } catch {\n          return null;\n        }\n      },\n    },\n    {\n      name: \"json.escape\",\n      description: \"Escape a JSON string, useful when using the output in JSON values\",\n      args: [\n        {\n          type: \"text\",\n          name: \"input\",\n          label: \"Input\",\n          multiLine: true,\n          placeholder: 'Hello \"World\"',\n        },\n      ],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        const input = String(args.values.input ?? \"\");\n        return input.replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n      },\n    },\n    {\n      name: \"json.minify\",\n      description: \"Remove unnecessary whitespace from a valid JSON string.\",\n      args: [\n        {\n          type: \"editor\",\n          language: \"json\",\n          name: \"input\",\n          label: \"Input\",\n          placeholder: '{ \"foo\": \"bar\" }',\n        },\n      ],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        const input = String(args.values.input ?? \"\");\n        try {\n          return JSON.stringify(JSON.parse(input));\n        } catch {\n          return input;\n        }\n      },\n    },\n  ],\n};\n\nexport type JSONPathResult = \"first\" | \"join\" | \"all\";\n\nexport function filterJSONPath(\n  body: string,\n  path: string,\n  result: JSONPathResult,\n  join: string | null,\n  formatted = false,\n): string {\n  const parsed = JSON.parse(body);\n  let items = JSONPath({ path, json: parsed });\n\n  if (items == null) {\n    return \"\";\n  }\n\n  if (!Array.isArray(items)) {\n    // Already good\n  } else if (result === \"first\") {\n    items = items[0] ?? \"\";\n  } else if (result === \"join\") {\n    items = items.map((i) => objToStr(i, false)).join(join ?? \"\");\n  }\n\n  return objToStr(items, formatted);\n}\n\nfunction objToStr(o: unknown, formatted = false): string {\n  if (\n    Object.prototype.toString.call(o) === \"[object Array]\" ||\n    Object.prototype.toString.call(o) === \"[object Object]\"\n  ) {\n    return formatted ? JSON.stringify(o, null, 2) : JSON.stringify(o);\n  }\n  return String(o);\n}\n"
  },
  {
    "path": "plugins/template-function-json/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-prompt/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-prompt\",\n  \"displayName\": \"Prompt Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template functions for prompting for user input\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  },\n  \"dependencies\": {\n    \"slugify\": \"^1.6.6\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-prompt/src/index.ts",
    "content": "import type { CallTemplateFunctionArgs, Context, PluginDefinition } from \"@yaakapp/api\";\nimport slugify from \"slugify\";\n\nconst STORE_NONE = \"none\";\nconst STORE_FOREVER = \"forever\";\nconst STORE_EXPIRE = \"expire\";\n\ninterface Saved {\n  value: string;\n  createdAt: number;\n}\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: [\n    {\n      name: \"prompt.text\",\n      description: \"Prompt the user for input when sending a request\",\n      previewType: \"click\",\n      previewArgs: [\"label\"],\n      args: [\n        {\n          type: \"text\",\n          name: \"label\",\n          label: \"Label\",\n          optional: true,\n          dynamic(_ctx, args) {\n            if (\n              args.values.store === STORE_EXPIRE ||\n              (args.values.store === STORE_FOREVER && !args.values.key)\n            ) {\n              return { optional: false };\n            }\n          },\n        },\n        {\n          type: \"select\",\n          name: \"store\",\n          label: \"Store Input\",\n          defaultValue: STORE_NONE,\n          options: [\n            { label: \"Never\", value: STORE_NONE },\n            { label: \"Expire\", value: STORE_EXPIRE },\n            { label: \"Forever\", value: STORE_FOREVER },\n          ],\n        },\n        {\n          type: \"h_stack\",\n          dynamic(_ctx, args) {\n            return { hidden: args.values.store === STORE_NONE };\n          },\n          inputs: [\n            {\n              type: \"text\",\n              name: \"namespace\",\n              label: \"Namespace\",\n              // oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax\n              defaultValue: \"${[ctx.workspace()]}\",\n              optional: true,\n            },\n            {\n              type: \"text\",\n              name: \"key\",\n              label: \"Key (defaults to Label)\",\n              optional: true,\n              dynamic(_ctx, args) {\n                return { placeholder: String(args.values.label || \"\") };\n              },\n            },\n            {\n              type: \"text\",\n              name: \"ttl\",\n              label: \"TTL (seconds)\",\n              placeholder: \"0\",\n              defaultValue: \"0\",\n              optional: true,\n              dynamic(_ctx, args) {\n                return { hidden: args.values.store !== STORE_EXPIRE };\n              },\n            },\n          ],\n        },\n        {\n          type: \"banner\",\n          color: \"info\",\n          inputs: [],\n          dynamic(_ctx, args) {\n            let key: string;\n            try {\n              key = buildKey(args);\n            } catch (err) {\n              return { color: \"danger\", inputs: [{ type: \"markdown\", content: String(err) }] };\n            }\n            return {\n              hidden: args.values.store === STORE_NONE,\n              inputs: [\n                {\n                  type: \"markdown\",\n                  content: [`Value will be saved under: \\`${key}\\``].join(\"\\n\\n\"),\n                },\n              ],\n            };\n          },\n        },\n        {\n          type: \"accordion\",\n          label: \"Advanced\",\n          inputs: [\n            {\n              type: \"text\",\n              name: \"title\",\n              label: \"Prompt Title\",\n              optional: true,\n              placeholder: \"Enter Value\",\n            },\n            { type: \"text\", name: \"defaultValue\", label: \"Default Value\", optional: true },\n            { type: \"text\", name: \"placeholder\", label: \"Input Placeholder\", optional: true },\n            { type: \"checkbox\", name: \"password\", label: \"Mask Value\" },\n          ],\n        },\n      ],\n      async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        if (args.purpose !== \"send\") return null;\n\n        if (args.values.store !== STORE_NONE && !args.values.namespace) {\n          throw new Error(\"Namespace is required when storing values\");\n        }\n\n        const existing = await maybeGetValue(ctx, args);\n        if (existing != null) {\n          return existing;\n        }\n\n        const value = await ctx.prompt.text({\n          id: `prompt-${args.values.label ?? \"none\"}`,\n          label: String(args.values.label || \"Value\"),\n          title: String(args.values.title ?? \"Enter Value\"),\n          defaultValue: String(args.values.defaultValue ?? \"\"),\n          placeholder: String(args.values.placeholder ?? \"\"),\n          password: Boolean(args.values.password),\n          required: false,\n        });\n\n        if (value == null) {\n          throw new Error(\"Prompt cancelled\");\n        }\n\n        if (args.values.store !== STORE_NONE) {\n          await maybeSetValue(ctx, args, value);\n        }\n\n        return value;\n      },\n    },\n  ],\n};\n\nfunction buildKey(args: CallTemplateFunctionArgs) {\n  if (!args.values.key && !args.values.label) {\n    throw new Error(\"A label or key is required when storing values\");\n  }\n  return [args.values.namespace, args.values.key || args.values.label]\n    .filter((v) => !!v)\n    .map((v) => slugify(String(v), { lower: true, trim: true }))\n    .join(\".\");\n}\n\nasync function maybeGetValue(ctx: Context, args: CallTemplateFunctionArgs) {\n  if (args.values.store === STORE_NONE) return null;\n\n  const existing = await ctx.store.get<Saved>(buildKey(args));\n  if (existing == null) {\n    return null;\n  }\n\n  if (args.values.store === STORE_FOREVER) {\n    return existing.value;\n  }\n\n  const ttlSeconds = Number.parseInt(String(args.values.ttl), 10) || 0;\n  const ageSeconds = (Date.now() - existing.createdAt) / 1000;\n  if (ageSeconds > ttlSeconds) {\n    ctx.store.delete(buildKey(args)).catch(console.error);\n    return null;\n  }\n\n  return existing.value;\n}\n\nasync function maybeSetValue(ctx: Context, args: CallTemplateFunctionArgs, value: string) {\n  if (args.values.store === STORE_NONE) {\n    return;\n  }\n\n  await ctx.store.set<Saved>(buildKey(args), { value, createdAt: Date.now() });\n}\n"
  },
  {
    "path": "plugins/template-function-prompt/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-random/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-random\",\n  \"displayName\": \"Random Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template functions for generating random values\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-random/src/index.ts",
    "content": "import type { CallTemplateFunctionArgs, Context, PluginDefinition } from \"@yaakapp/api\";\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: [\n    {\n      name: \"random.range\",\n      description: \"Generate a random number between two values\",\n      previewArgs: [\"min\", \"max\"],\n      args: [\n        {\n          type: \"text\",\n          name: \"min\",\n          label: \"Minimum\",\n          defaultValue: \"0\",\n        },\n        {\n          type: \"text\",\n          name: \"max\",\n          label: \"Maximum\",\n          defaultValue: \"1\",\n        },\n        {\n          type: \"text\",\n          name: \"decimals\",\n          optional: true,\n          label: \"Decimal Places\",\n        },\n      ],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        const min = args.values.min ? Number.parseInt(String(args.values.min ?? \"0\"), 10) : 0;\n        const max = args.values.max ? Number.parseInt(String(args.values.max ?? \"1\"), 10) : 1;\n        const decimals = args.values.decimals\n          ? Number.parseInt(String(args.values.decimals ?? \"0\"), 10)\n          : null;\n\n        let value = Math.random() * (max - min) + min;\n        if (decimals !== null) {\n          value = Math.round(value * 10 ** decimals) / 10 ** decimals;\n        }\n        return String(value);\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "plugins/template-function-random/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-regex/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-regex\",\n  \"displayName\": \"Regex Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template functions for working with regular expressions\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-regex/src/index.ts",
    "content": "import type { CallTemplateFunctionArgs, Context, PluginDefinition } from \"@yaakapp/api\";\nimport type { TemplateFunctionArg } from \"@yaakapp-internal/plugins\";\n\nconst inputArg: TemplateFunctionArg = {\n  type: \"text\",\n  name: \"input\",\n  label: \"Input Text\",\n  multiLine: true,\n};\n\nconst regexArg: TemplateFunctionArg = {\n  type: \"text\",\n  name: \"regex\",\n  label: \"Regular Expression\",\n  placeholder: \"\\\\w+\",\n  defaultValue: \".*\",\n  description:\n    \"A JavaScript regular expression. Use a capture group to reference parts of the match in the replacement.\",\n};\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: [\n    {\n      name: \"regex.match\",\n      description: \"Extract text using a regular expression\",\n      args: [inputArg, regexArg],\n      previewArgs: [regexArg.name],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        const input = String(args.values.input ?? \"\");\n        const regex = new RegExp(String(args.values.regex ?? \"\"));\n\n        const match = input.match(regex);\n        return match?.groups\n          ? (Object.values(match.groups)[0] ?? \"\")\n          : (match?.[1] ?? match?.[0] ?? \"\");\n      },\n    },\n    {\n      name: \"regex.replace\",\n      description: \"Replace text using a regular expression\",\n      previewArgs: [regexArg.name],\n      args: [\n        inputArg,\n        regexArg,\n        {\n          type: \"text\",\n          name: \"replacement\",\n          label: \"Replacement Text\",\n          placeholder: \"hello $1\",\n          description:\n            \"The replacement text. Use $1, $2, ... to reference capture groups or $& to reference the entire match.\",\n        },\n        {\n          type: \"text\",\n          name: \"flags\",\n          label: \"Flags\",\n          placeholder: \"g\",\n          defaultValue: \"g\",\n          optional: true,\n          description:\n            \"Regular expression flags (g for global, i for case-insensitive, m for multiline, etc.)\",\n        },\n      ],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        const input = String(args.values.input ?? \"\");\n        const replacement = String(args.values.replacement ?? \"\");\n        const flags = String(args.values.flags || \"\");\n        const regex = String(args.values.regex);\n\n        if (!regex) return \"\";\n\n        return input.replace(new RegExp(String(args.values.regex), flags), replacement);\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "plugins/template-function-regex/tests/regex.test.ts",
    "content": "import type { Context } from \"@yaakapp/api\";\nimport { describe, expect, it } from \"vite-plus/test\";\nimport { plugin } from \"../src\";\n\ndescribe(\"regex.match\", () => {\n  const matchFunction = plugin.templateFunctions?.find((f) => f.name === \"regex.match\");\n\n  it(\"should exist\", () => {\n    expect(matchFunction).toBeDefined();\n  });\n\n  it(\"should extract first capture group\", async () => {\n    const result = await matchFunction?.onRender({} as Context, {\n      values: {\n        regex: \"Hello (\\\\w+)\",\n        input: \"Hello World\",\n      },\n      purpose: \"send\",\n    });\n    expect(result).toBe(\"World\");\n  });\n\n  it(\"should extract named capture group\", async () => {\n    const result = await matchFunction?.onRender({} as Context, {\n      values: {\n        regex: \"Hello (?<name>\\\\w+)\",\n        input: \"Hello World\",\n      },\n      purpose: \"send\",\n    });\n    expect(result).toBe(\"World\");\n  });\n\n  it(\"should return full match when no capture groups\", async () => {\n    const result = await matchFunction?.onRender({} as Context, {\n      values: {\n        regex: \"Hello \\\\w+\",\n        input: \"Hello World\",\n      },\n      purpose: \"send\",\n    });\n    expect(result).toBe(\"Hello World\");\n  });\n\n  it(\"should return empty string when no match\", async () => {\n    const result = await matchFunction?.onRender({} as Context, {\n      values: {\n        regex: \"Goodbye\",\n        input: \"Hello World\",\n      },\n      purpose: \"send\",\n    });\n    expect(result).toBe(\"\");\n  });\n\n  it(\"should return empty string when regex is empty\", async () => {\n    const result = await matchFunction?.onRender({} as Context, {\n      values: {\n        regex: \"\",\n        input: \"Hello World\",\n      },\n      purpose: \"send\",\n    });\n    expect(result).toBe(\"\");\n  });\n\n  it(\"should return empty string when input is empty\", async () => {\n    const result = await matchFunction?.onRender({} as Context, {\n      values: {\n        regex: \"Hello\",\n        input: \"\",\n      },\n      purpose: \"send\",\n    });\n    expect(result).toBe(\"\");\n  });\n});\n\ndescribe(\"regex.replace\", () => {\n  const replaceFunction = plugin.templateFunctions?.find((f) => f.name === \"regex.replace\");\n\n  it(\"should exist\", () => {\n    expect(replaceFunction).toBeDefined();\n  });\n\n  it(\"should replace one occurrence by default\", async () => {\n    const result = await replaceFunction?.onRender({} as Context, {\n      values: {\n        regex: \"o\",\n        input: \"Hello World\",\n        replacement: \"a\",\n      },\n      purpose: \"send\",\n    });\n    expect(result).toBe(\"Hella World\");\n  });\n\n  it(\"should replace with capture groups\", async () => {\n    const result = await replaceFunction?.onRender({} as Context, {\n      values: {\n        regex: \"(\\\\w+) (\\\\w+)\",\n        input: \"Hello World\",\n        replacement: \"$2 $1\",\n      },\n      purpose: \"send\",\n    });\n    expect(result).toBe(\"World Hello\");\n  });\n\n  it(\"should replace with full match reference\", async () => {\n    const result = await replaceFunction?.onRender({} as Context, {\n      values: {\n        regex: \"World\",\n        input: \"Hello World\",\n        replacement: \"[$&]\",\n      },\n      purpose: \"send\",\n    });\n    expect(result).toBe(\"Hello [World]\");\n  });\n\n  it(\"should respect flags parameter\", async () => {\n    const result = await replaceFunction?.onRender({} as Context, {\n      values: {\n        regex: \"hello\",\n        input: \"Hello World\",\n        replacement: \"Hi\",\n        flags: \"i\",\n      },\n      purpose: \"send\",\n    });\n    expect(result).toBe(\"Hi World\");\n  });\n\n  it(\"should handle empty replacement\", async () => {\n    const result = await replaceFunction?.onRender({} as Context, {\n      values: {\n        regex: \"World\",\n        input: \"Hello World\",\n        replacement: \"\",\n      },\n      purpose: \"send\",\n    });\n    expect(result).toBe(\"Hello \");\n  });\n\n  it(\"should return original input when no match\", async () => {\n    const result = await replaceFunction?.onRender({} as Context, {\n      values: {\n        regex: \"Goodbye\",\n        input: \"Hello World\",\n        replacement: \"Hi\",\n      },\n      purpose: \"send\",\n    });\n    expect(result).toBe(\"Hello World\");\n  });\n\n  it(\"should return empty string when regex is empty\", async () => {\n    const result = await replaceFunction?.onRender({} as Context, {\n      values: {\n        regex: \"\",\n        input: \"Hello World\",\n        replacement: \"Hi\",\n      },\n      purpose: \"send\",\n    });\n    expect(result).toBe(\"\");\n  });\n\n  it(\"should return empty string when input is empty\", async () => {\n    const result = await replaceFunction?.onRender({} as Context, {\n      values: {\n        regex: \"Hello\",\n        input: \"\",\n        replacement: \"Hi\",\n      },\n      purpose: \"send\",\n    });\n    expect(result).toBe(\"\");\n  });\n\n  it(\"should throw on invalid regex\", async () => {\n    const fn = replaceFunction?.onRender({} as Context, {\n      values: {\n        regex: \"[\",\n        input: \"Hello World\",\n        replacement: \"Hi\",\n      },\n      purpose: \"send\",\n    });\n    await expect(fn).rejects.toThrow(\n      \"Invalid regular expression: /[/: Unterminated character class\",\n    );\n  });\n});\n"
  },
  {
    "path": "plugins/template-function-regex/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-request/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-request\",\n  \"displayName\": \"Request Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template functions for extracting value from requests\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-request/src/index.ts",
    "content": "import type { CallTemplateFunctionArgs, Context, PluginDefinition } from \"@yaakapp/api\";\nimport type { AnyModel, HttpUrlParameter } from \"@yaakapp-internal/models\";\nimport type { GenericCompletionOption } from \"@yaakapp-internal/plugins\";\nimport type { JSONPathResult } from \"../../template-function-json\";\nimport { filterJSONPath } from \"../../template-function-json\";\nimport type { XPathResult } from \"../../template-function-xml\";\nimport { filterXPath } from \"../../template-function-xml\";\n\nconst RETURN_FIRST = \"first\";\nconst RETURN_ALL = \"all\";\nconst RETURN_JOIN = \"join\";\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: [\n    {\n      name: \"request.body.raw\",\n      aliases: [\"request.body\"],\n      args: [\n        {\n          name: \"requestId\",\n          label: \"Http Request\",\n          type: \"http_request\",\n        },\n      ],\n      async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        const requestId = String(args.values.requestId ?? \"n/a\");\n        const httpRequest = await ctx.httpRequest.getById({ id: requestId });\n        if (httpRequest == null) return null;\n        return String(\n          await ctx.templates.render({\n            data: httpRequest.body?.text ?? \"\",\n            purpose: args.purpose,\n          }),\n        );\n      },\n    },\n    {\n      name: \"request.body.path\",\n      previewArgs: [\"path\"],\n      args: [\n        { name: \"requestId\", label: \"Http Request\", type: \"http_request\" },\n        {\n          type: \"h_stack\",\n          inputs: [\n            {\n              type: \"select\",\n              name: \"result\",\n              label: \"Return Format\",\n              defaultValue: RETURN_FIRST,\n              options: [\n                { label: \"First result\", value: RETURN_FIRST },\n                { label: \"All results\", value: RETURN_ALL },\n                { label: \"Join with separator\", value: RETURN_JOIN },\n              ],\n            },\n            {\n              name: \"join\",\n              type: \"text\",\n              label: \"Separator\",\n              optional: true,\n              defaultValue: \", \",\n              dynamic(_ctx, args) {\n                return { hidden: args.values.result !== RETURN_JOIN };\n              },\n            },\n          ],\n        },\n        {\n          type: \"text\",\n          name: \"path\",\n          label: \"JSONPath or XPath\",\n          placeholder: \"$.books[0].id or /books[0]/id\",\n          dynamic: async (ctx, args) => {\n            const requestId = String(args.values.requestId ?? \"n/a\");\n            const httpRequest = await ctx.httpRequest.getById({ id: requestId });\n            if (httpRequest == null) return null;\n\n            const contentType =\n              httpRequest.headers\n                .find((h) => h.name.toLowerCase() === \"content-type\")\n                ?.value.toLowerCase() ?? \"\";\n            if (contentType.includes(\"xml\") || contentType?.includes(\"html\")) {\n              return {\n                label: \"XPath\",\n                placeholder: \"/books[0]/id\",\n                description: \"Enter an XPath expression used to filter the results\",\n              };\n            }\n\n            return {\n              label: \"JSONPath\",\n              placeholder: \"$.books[0].id\",\n              description: \"Enter a JSONPath expression used to filter the results\",\n            };\n          },\n        },\n      ],\n      async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        const requestId = String(args.values.requestId ?? \"n/a\");\n        const httpRequest = await ctx.httpRequest.getById({ id: requestId });\n        if (httpRequest == null) return null;\n\n        const body = httpRequest.body?.text ?? \"\";\n\n        try {\n          const result: JSONPathResult =\n            args.values.result === RETURN_ALL\n              ? \"all\"\n              : args.values.result === RETURN_JOIN\n                ? \"join\"\n                : \"first\";\n          return filterJSONPath(\n            body,\n            String(args.values.path || \"\"),\n            result,\n            args.values.join == null ? null : String(args.values.join),\n          );\n        } catch {\n          // Probably not JSON, try XPath\n        }\n\n        try {\n          const result: XPathResult =\n            args.values.result === RETURN_ALL\n              ? \"all\"\n              : args.values.result === RETURN_JOIN\n                ? \"join\"\n                : \"first\";\n          return filterXPath(\n            body,\n            String(args.values.path || \"\"),\n            result,\n            args.values.join == null ? null : String(args.values.join),\n          );\n        } catch {\n          // Probably not XML\n        }\n\n        return null; // Bail out\n      },\n    },\n    {\n      name: \"request.header\",\n      description: \"Read the value of a request header, by name\",\n      previewArgs: [\"header\"],\n      args: [\n        {\n          name: \"requestId\",\n          label: \"Http Request\",\n          type: \"http_request\",\n        },\n        {\n          name: \"header\",\n          label: \"Header Name\",\n          type: \"text\",\n          async dynamic(ctx, args) {\n            if (typeof args.values.requestId !== \"string\") return null;\n\n            const request = await ctx.httpRequest.getById({ id: args.values.requestId });\n            if (request == null) return null;\n\n            const validHeaders = request.headers.filter((h) => h.enabled !== false && h.name);\n            return {\n              placeholder: validHeaders[0]?.name,\n              completionOptions: validHeaders.map<GenericCompletionOption>((h) => ({\n                label: h.name,\n                type: \"constant\",\n              })),\n            };\n          },\n        },\n      ],\n      async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        const headerName = String(args.values.header ?? \"\");\n        const requestId = String(args.values.requestId ?? \"n/a\");\n        const httpRequest = await ctx.httpRequest.getById({ id: requestId });\n        if (httpRequest == null) return null;\n        const header = httpRequest.headers.find(\n          (h) => h.name.toLowerCase() === headerName.toLowerCase(),\n        );\n        return String(\n          await ctx.templates.render({\n            data: header?.value ?? \"\",\n            purpose: args.purpose,\n          }),\n        );\n      },\n    },\n    {\n      name: \"request.param\",\n      args: [\n        {\n          name: \"requestId\",\n          label: \"Http Request\",\n          type: \"http_request\",\n        },\n        {\n          name: \"param\",\n          label: \"Param Name\",\n          type: \"text\",\n        },\n      ],\n      async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        const paramName = String(args.values.param ?? \"\");\n        const requestId = String(args.values.requestId ?? \"n/a\");\n        const httpRequest = await ctx.httpRequest.getById({ id: requestId });\n        if (httpRequest == null) return null;\n\n        const renderedUrl = await ctx.templates.render({\n          data: httpRequest.url,\n          purpose: args.purpose,\n        });\n\n        const querystring = renderedUrl.split(\"?\")[1] ?? \"\";\n        const paramsFromUrl: HttpUrlParameter[] = new URLSearchParams(querystring)\n          .entries()\n          .map(([name, value]): HttpUrlParameter => ({ name, value }))\n          .toArray();\n\n        const allParams = [...paramsFromUrl, ...httpRequest.urlParameters];\n        const allEnabledParams = allParams.filter((p) => p.enabled !== false);\n        const foundParam = allEnabledParams.find((p) => p.name === paramName);\n\n        const renderedValue = await ctx.templates.render({\n          data: foundParam?.value ?? \"\",\n          purpose: args.purpose,\n        });\n        return renderedValue;\n      },\n    },\n    {\n      name: \"request.name\",\n      args: [\n        {\n          name: \"requestId\",\n          label: \"Http Request\",\n          type: \"http_request\",\n        },\n      ],\n      async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        const requestId = String(args.values.requestId ?? \"n/a\");\n        const httpRequest = await ctx.httpRequest.getById({ id: requestId });\n        if (httpRequest == null) return null;\n\n        return resolvedModelName(httpRequest);\n      },\n    },\n  ],\n};\n\n// TODO: Use a common function for this, but it fails to build on windows during CI if I try importing it here\nexport function resolvedModelName(r: AnyModel | null): string {\n  if (r == null) return \"\";\n\n  if (!(\"url\" in r) || r.model === \"plugin\") {\n    return \"name\" in r ? r.name : \"\";\n  }\n\n  // Return name if it has one\n  if (\"name\" in r && r.name) {\n    return r.name;\n  }\n\n  // Replace variable syntax with variable name\n  const withoutVariables = r.url.replace(/\\$\\{\\[\\s*([^\\]\\s]+)\\s*]}/g, \"$1\");\n  if (withoutVariables.trim() === \"\") {\n    return r.model === \"http_request\"\n      ? r.bodyType && r.bodyType === \"graphql\"\n        ? \"GraphQL Request\"\n        : \"HTTP Request\"\n      : r.model === \"websocket_request\"\n        ? \"WebSocket Request\"\n        : \"gRPC Request\";\n  }\n\n  // GRPC gets nice short names\n  if (r.model === \"grpc_request\" && r.service != null && r.method != null) {\n    const shortService = r.service.split(\".\").pop();\n    return `${shortService}/${r.method}`;\n  }\n\n  // Strip unnecessary protocol\n  const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\\/\\//, \"\");\n\n  return withoutProto;\n}\n"
  },
  {
    "path": "plugins/template-function-request/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-response/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-response\",\n  \"displayName\": \"Response Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template functions for request chaining\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  },\n  \"dependencies\": {\n    \"@yaak/template-function-xml\": \"*\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-response/src/index.ts",
    "content": "import { readFileSync } from \"node:fs\";\nimport type {\n  CallTemplateFunctionArgs,\n  Context,\n  DynamicTemplateFunctionArg,\n  FormInput,\n  HttpResponse,\n  PluginDefinition,\n  RenderPurpose,\n} from \"@yaakapp/api\";\nimport type { GenericCompletionOption } from \"@yaakapp-internal/plugins\";\nimport type { JSONPathResult } from \"../../template-function-json\";\nimport { filterJSONPath } from \"../../template-function-json\";\nimport type { XPathResult } from \"../../template-function-xml\";\nimport { filterXPath } from \"../../template-function-xml\";\n\nconst BEHAVIOR_TTL = \"ttl\";\nconst BEHAVIOR_ALWAYS = \"always\";\nconst BEHAVIOR_SMART = \"smart\";\n\nconst RETURN_FIRST = \"first\";\nconst RETURN_ALL = \"all\";\nconst RETURN_JOIN = \"join\";\n\nconst behaviorArgs: DynamicTemplateFunctionArg = {\n  type: \"h_stack\",\n  inputs: [\n    {\n      type: \"select\",\n      name: \"behavior\",\n      label: \"Sending Behavior\",\n      defaultValue: BEHAVIOR_SMART,\n      options: [\n        { label: \"When no responses\", value: BEHAVIOR_SMART },\n        { label: \"Always\", value: BEHAVIOR_ALWAYS },\n        { label: \"When expired\", value: BEHAVIOR_TTL },\n      ],\n    },\n    {\n      type: \"text\",\n      name: \"ttl\",\n      label: \"TTL (seconds)\",\n      placeholder: \"0\",\n      defaultValue: \"0\",\n      description:\n        'Resend the request when the latest response is older than this many seconds, or if there are no responses yet. \"0\" means never expires',\n      dynamic(_ctx, args) {\n        return { hidden: args.values.behavior !== BEHAVIOR_TTL };\n      },\n    },\n  ],\n};\n\nconst requestArg: FormInput = {\n  type: \"http_request\",\n  name: \"request\",\n  label: \"Request\",\n  defaultValue: \"\", // Make it not select the active one by default\n};\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: [\n    {\n      name: \"response.header\",\n      description: \"Read the value of a response header, by name\",\n      previewArgs: [\"header\"],\n      args: [\n        requestArg,\n        behaviorArgs,\n        {\n          type: \"text\",\n          name: \"header\",\n          label: \"Header Name\",\n          async dynamic(ctx, args) {\n            // Dynamic form config also runs during send-time rendering.\n            // Keep this preview-only to avoid side-effect request sends.\n            if (args.purpose !== \"preview\") return null;\n\n            const response = await getResponse(ctx, {\n              requestId: String(args.values.request || \"\"),\n              purpose: args.purpose,\n              behavior: args.values.behavior ? String(args.values.behavior) : null,\n              ttl: String(args.values.ttl || \"\"),\n            });\n\n            return {\n              placeholder: response?.headers[0]?.name,\n              completionOptions: response?.headers.map<GenericCompletionOption>((h) => ({\n                label: h.name,\n                type: \"constant\",\n              })),\n            };\n          },\n        },\n      ],\n      async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        if (!args.values.request || !args.values.header) return null;\n\n        const response = await getResponse(ctx, {\n          requestId: String(args.values.request || \"\"),\n          purpose: args.purpose,\n          behavior: args.values.behavior ? String(args.values.behavior) : null,\n          ttl: String(args.values.ttl || \"\"),\n        });\n        if (response == null) return null;\n\n        const header = response.headers.find(\n          (h) => h.name.toLowerCase() === String(args.values.header ?? \"\").toLowerCase(),\n        );\n        return header?.value ?? null;\n      },\n    },\n    {\n      name: \"response.body.path\",\n      description: \"Access a field of the response body using JsonPath or XPath\",\n      aliases: [\"response\"],\n      previewArgs: [\"path\"],\n      args: [\n        requestArg,\n        behaviorArgs,\n        {\n          type: \"h_stack\",\n          inputs: [\n            {\n              type: \"select\",\n              name: \"result\",\n              label: \"Return Format\",\n              defaultValue: RETURN_FIRST,\n              options: [\n                { label: \"First result\", value: RETURN_FIRST },\n                { label: \"All results\", value: RETURN_ALL },\n                { label: \"Join with separator\", value: RETURN_JOIN },\n              ],\n            },\n            {\n              name: \"join\",\n              type: \"text\",\n              label: \"Separator\",\n              optional: true,\n              defaultValue: \", \",\n              dynamic(_ctx, args) {\n                return { hidden: args.values.result !== RETURN_JOIN };\n              },\n            },\n          ],\n        },\n        {\n          type: \"text\",\n          name: \"path\",\n          label: \"JSONPath or XPath\",\n          placeholder: \"$.books[0].id or /books[0]/id\",\n          dynamic: async (ctx, args) => {\n            // Dynamic form config also runs during send-time rendering.\n            // Keep this preview-only to avoid side-effect request sends.\n            if (args.purpose !== \"preview\") return null;\n\n            const resp = await getResponse(ctx, {\n              requestId: String(args.values.request || \"\"),\n              purpose: \"preview\",\n              behavior: args.values.behavior ? String(args.values.behavior) : null,\n              ttl: String(args.values.ttl || \"\"),\n            });\n\n            if (resp == null) {\n              return null;\n            }\n\n            const contentType =\n              resp?.headers\n                .find((h) => h.name.toLowerCase() === \"content-type\")\n                ?.value.toLowerCase() ?? \"\";\n            if (contentType.includes(\"xml\") || contentType?.includes(\"html\")) {\n              return {\n                label: \"XPath\",\n                placeholder: \"/books[0]/id\",\n                description: \"Enter an XPath expression used to filter the results\",\n              };\n            }\n\n            return {\n              label: \"JSONPath\",\n              placeholder: \"$.books[0].id\",\n              description: \"Enter a JSONPath expression used to filter the results\",\n            };\n          },\n        },\n      ],\n      async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        if (!args.values.request || !args.values.path) return null;\n\n        const response = await getResponse(ctx, {\n          requestId: String(args.values.request || \"\"),\n          purpose: args.purpose,\n          behavior: args.values.behavior ? String(args.values.behavior) : null,\n          ttl: String(args.values.ttl || \"\"),\n        });\n        if (response == null) return null;\n\n        if (response.bodyPath == null) {\n          return null;\n        }\n\n        const BOM = \"\\ufeff\";\n        let body: string;\n        try {\n          body = readFileSync(response.bodyPath, \"utf-8\").replace(BOM, \"\");\n        } catch {\n          return null;\n        }\n\n        try {\n          const result: JSONPathResult =\n            args.values.result === RETURN_ALL\n              ? \"all\"\n              : args.values.result === RETURN_JOIN\n                ? \"join\"\n                : \"first\";\n          return filterJSONPath(\n            body,\n            String(args.values.path || \"\"),\n            result,\n            args.values.join == null ? null : String(args.values.join),\n          );\n        } catch {\n          // Probably not JSON, try XPath\n        }\n\n        try {\n          const result: XPathResult =\n            args.values.result === RETURN_ALL\n              ? \"all\"\n              : args.values.result === RETURN_JOIN\n                ? \"join\"\n                : \"first\";\n          return filterXPath(\n            body,\n            String(args.values.path || \"\"),\n            result,\n            args.values.join == null ? null : String(args.values.join),\n          );\n        } catch {\n          // Probably not XML\n        }\n\n        return null; // Bail out\n      },\n    },\n    {\n      name: \"response.body.raw\",\n      description: \"Access the entire response body, as text\",\n      aliases: [\"response\"],\n      args: [requestArg, behaviorArgs],\n      async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        if (!args.values.request) return null;\n\n        const response = await getResponse(ctx, {\n          requestId: String(args.values.request || \"\"),\n          purpose: args.purpose,\n          behavior: args.values.behavior ? String(args.values.behavior) : null,\n          ttl: String(args.values.ttl || \"\"),\n        });\n        if (response == null) return null;\n\n        if (response.bodyPath == null) {\n          return null;\n        }\n\n        let body: string;\n        try {\n          body = readFileSync(response.bodyPath, \"utf-8\");\n        } catch {\n          return null;\n        }\n\n        return body;\n      },\n    },\n  ],\n};\n\nasync function getResponse(\n  ctx: Context,\n  {\n    requestId,\n    behavior,\n    purpose,\n    ttl,\n  }: {\n    requestId: string;\n    behavior: string | null;\n    ttl: string | null;\n    purpose: RenderPurpose;\n  },\n): Promise<HttpResponse | null> {\n  if (!requestId) return null;\n\n  const httpRequest = await ctx.httpRequest.getById({ id: requestId ?? \"n/a\" });\n  if (httpRequest == null) {\n    return null;\n  }\n\n  const responses = await ctx.httpResponse.find({ requestId: httpRequest.id, limit: 1 });\n\n  if (behavior === \"never\" && responses.length === 0) {\n    return null;\n  }\n\n  let response: HttpResponse | null = responses[0] ?? null;\n\n  // Previews happen a ton, and we don't want to send too many times on \"always,\" so treat\n  // it as \"smart\" during preview.\n  const finalBehavior = behavior === \"always\" && purpose === \"preview\" ? \"smart\" : behavior;\n\n  // Send if no responses and \"smart,\" or \"always\"\n  if (\n    (finalBehavior === \"smart\" && response == null) ||\n    finalBehavior === \"always\" ||\n    (finalBehavior === BEHAVIOR_TTL && shouldSendExpired(response, ttl))\n  ) {\n    // Explicitly render the request before send (instead of relying on send() to render) so that we can\n    // preserve the render purpose.\n    const renderedHttpRequest = await ctx.httpRequest.render({ httpRequest, purpose });\n    response = await ctx.httpRequest.send({ httpRequest: renderedHttpRequest });\n  }\n\n  return response;\n}\n\nfunction shouldSendExpired(response: HttpResponse | null, ttl: string | null): boolean {\n  if (response == null) return true;\n  const ttlSeconds = Number.parseInt(ttl || \"0\", 10) || 0;\n  if (ttlSeconds === 0) return false;\n  const nowMillis = Date.now();\n  const respMillis = new Date(`${response.createdAt}Z`).getTime();\n  return respMillis + ttlSeconds * 1000 < nowMillis;\n}\n"
  },
  {
    "path": "plugins/template-function-response/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-timestamp/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-timestamp\",\n  \"displayName\": \"Timestamp Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template functions for dealing with timestamps\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  },\n  \"dependencies\": {\n    \"@date-fns/tz\": \"^1.4.1\",\n    \"date-fns\": \"^4.1.0\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-timestamp/src/index.ts",
    "content": "import type { PluginDefinition } from \"@yaakapp/api\";\nimport type { TemplateFunctionArg } from \"@yaakapp-internal/plugins\";\n\nimport type { ContextFn } from \"date-fns\";\nimport {\n  addDays,\n  addHours,\n  addMinutes,\n  addMonths,\n  addSeconds,\n  addYears,\n  format as formatDate,\n  isValid,\n  parseISO,\n  subDays,\n  subHours,\n  subMinutes,\n  subMonths,\n  subSeconds,\n  subYears,\n} from \"date-fns\";\n\nconst dateArg: TemplateFunctionArg = {\n  type: \"text\",\n  name: \"date\",\n  label: \"Timestamp\",\n  optional: true,\n  description:\n    \"Can be a timestamp in milliseconds, ISO string, or anything parseable by JS `new Date()`\",\n  placeholder: new Date().toISOString(),\n};\n\nconst expressionArg: TemplateFunctionArg = {\n  type: \"text\",\n  name: \"expression\",\n  label: \"Expression\",\n  description: \"Modification expression (eg. '-5d +2h 3m'). Available units: y, M, d, h, m, s\",\n  optional: true,\n  placeholder: \"-5d +2h 3m\",\n};\n\nconst formatArg: TemplateFunctionArg = {\n  name: \"format\",\n  label: \"Format String\",\n  description: \"Format string to describe the output (eg. 'yyyy-MM-dd at HH:mm:ss')\",\n  optional: true,\n  placeholder: \"yyyy-MM-dd HH:mm:ss\",\n  type: \"text\",\n};\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: [\n    {\n      name: \"timestamp.unix\",\n      description: \"Get the timestamp in seconds\",\n      args: [dateArg],\n      onRender: async (_ctx, args) => {\n        const d = parseDateString(String(args.values.date ?? \"\"));\n        return String(Math.floor(d.getTime() / 1000));\n      },\n    },\n    {\n      name: \"timestamp.unixMillis\",\n      description: \"Get the timestamp in milliseconds\",\n      args: [dateArg],\n      onRender: async (_ctx, args) => {\n        const d = parseDateString(String(args.values.date ?? \"\"));\n        return String(d.getTime());\n      },\n    },\n    {\n      name: \"timestamp.iso8601\",\n      description: \"Get the date in ISO8601 format\",\n      args: [dateArg],\n      onRender: async (_ctx, args) => {\n        const d = parseDateString(String(args.values.date ?? \"\"));\n        return d.toISOString();\n      },\n    },\n    {\n      name: \"timestamp.format\",\n      description: \"Format a date using a dayjs-compatible format string\",\n      args: [dateArg, formatArg],\n      previewArgs: [formatArg.name],\n      onRender: async (_ctx, args) => formatDatetime(args.values),\n    },\n    {\n      name: \"timestamp.offset\",\n      description: \"Get the offset of a date based on an expression\",\n      args: [dateArg, expressionArg],\n      previewArgs: [expressionArg.name],\n      onRender: async (_ctx, args) => calculateDatetime(args.values),\n    },\n  ],\n};\n\nfunction applyDateOp(d: Date, sign: string, amount: number, unit: string): Date {\n  switch (unit) {\n    case \"y\":\n      return sign === \"-\" ? subYears(d, amount) : addYears(d, amount);\n    case \"M\":\n      return sign === \"-\" ? subMonths(d, amount) : addMonths(d, amount);\n    case \"d\":\n      return sign === \"-\" ? subDays(d, amount) : addDays(d, amount);\n    case \"h\":\n      return sign === \"-\" ? subHours(d, amount) : addHours(d, amount);\n    case \"m\":\n      return sign === \"-\" ? subMinutes(d, amount) : addMinutes(d, amount);\n    case \"s\":\n      return sign === \"-\" ? subSeconds(d, amount) : addSeconds(d, amount);\n    default:\n      throw new Error(`Invalid data calculation unit: ${unit}`);\n  }\n}\n\nfunction parseOp(op: string): { sign: string; amount: number; unit: string } | null {\n  const match = op.match(/^([+-]?)(\\d+)([yMdhms])$/);\n  if (!match) {\n    throw new Error(`Invalid date expression: ${op}`);\n  }\n  const [, sign, amount, unit] = match;\n  if (!unit) return null;\n  return { sign: sign ?? \"+\", amount: Number(amount ?? 0), unit };\n}\n\nfunction parseDateString(date: string): Date {\n  if (!date.trim()) {\n    return new Date();\n  }\n\n  const isoDate = parseISO(date);\n  if (isValid(isoDate)) {\n    return isoDate;\n  }\n\n  const jsDate = /^\\d+(\\.\\d+)?$/.test(date) ? new Date(Number(date)) : new Date(date);\n  if (isValid(jsDate)) {\n    return jsDate;\n  }\n\n  throw new Error(`Invalid date: ${date}`);\n}\n\nexport function calculateDatetime(args: { date?: string; expression?: string }): string {\n  const { date, expression } = args;\n  let jsDate = parseDateString(date ?? \"\");\n\n  if (expression) {\n    const ops = String(expression)\n      .split(\" \")\n      .map((s) => s.trim())\n      .filter(Boolean);\n    for (const op of ops) {\n      const parsed = parseOp(op);\n      if (parsed) {\n        jsDate = applyDateOp(jsDate, parsed.sign, parsed.amount, parsed.unit);\n      }\n    }\n  }\n\n  return jsDate.toISOString();\n}\n\nexport function formatDatetime(args: {\n  date?: string;\n  format?: string;\n  in?: ContextFn<Date>;\n}): string {\n  const { date, format } = args;\n  const d = parseDateString(date ?? \"\");\n  return formatDate(d, String(format || \"yyyy-MM-dd HH:mm:ss\"), { in: args.in });\n}\n"
  },
  {
    "path": "plugins/template-function-timestamp/tests/formatDatetime.test.ts",
    "content": "import { tz } from \"@date-fns/tz\";\nimport { describe, expect, it } from \"vite-plus/test\";\nimport { calculateDatetime, formatDatetime } from \"../src\";\n\ndescribe(\"formatDatetime\", () => {\n  it(\"returns formatted current date\", () => {\n    const result = formatDatetime({});\n    expect(result).toMatch(/\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}/);\n  });\n\n  it(\"returns formatted specific date\", () => {\n    const result = formatDatetime({ date: \"2025-07-13T12:34:56\" });\n    expect(result).toBe(\"2025-07-13 12:34:56\");\n  });\n\n  it(\"returns formatted specific timestamp\", () => {\n    const result = formatDatetime({ date: \"1752435296000\", in: tz(\"America/Vancouver\") });\n    expect(result).toBe(\"2025-07-13 12:34:56\");\n  });\n\n  it(\"returns formatted specific timestamp with decimals\", () => {\n    const result = formatDatetime({ date: \"1752435296000.19\", in: tz(\"America/Vancouver\") });\n    expect(result).toBe(\"2025-07-13 12:34:56\");\n  });\n\n  it(\"returns formatted date with custom output\", () => {\n    const result = formatDatetime({ date: \"2025-07-13T12:34:56\", format: \"dd/MM/yyyy\" });\n    expect(result).toBe(\"13/07/2025\");\n  });\n\n  it(\"handles invalid date gracefully\", () => {\n    expect(() => formatDatetime({ date: \"invalid-date\" })).toThrow(\"Invalid date: invalid-date\");\n  });\n});\n\ndescribe(\"calculateDatetime\", () => {\n  it(\"returns ISO string for current date\", () => {\n    const result = calculateDatetime({});\n    expect(result).toMatch(/\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/);\n  });\n\n  it(\"returns ISO string for specific date\", () => {\n    const result = calculateDatetime({ date: \"2025-07-13T12:34:56Z\" });\n    expect(result).toBe(\"2025-07-13T12:34:56.000Z\");\n  });\n\n  it(\"applies calc operations\", () => {\n    const result = calculateDatetime({ date: \"2025-07-13T12:00:00Z\", expression: \"+1d 2h\" });\n    expect(result).toBe(\"2025-07-14T14:00:00.000Z\");\n  });\n\n  it(\"applies negative calc operations\", () => {\n    const result = calculateDatetime({ date: \"2025-07-13T12:00:00Z\", expression: \"-1d -2h 1m\" });\n    expect(result).toBe(\"2025-07-12T10:01:00.000Z\");\n  });\n\n  it(\"throws error for invalid unit\", () => {\n    expect(() => calculateDatetime({ date: \"2025-07-13T12:00:00Z\", expression: \"+1x\" })).toThrow(\n      \"Invalid date expression: +1x\",\n    );\n  });\n  it(\"throws error for invalid unit weird\", () => {\n    expect(() => calculateDatetime({ date: \"2025-07-13T12:00:00Z\", expression: \"+1&#^%\" })).toThrow(\n      \"Invalid date expression: +1&#^%\",\n    );\n  });\n  it(\"throws error for bad expression\", () => {\n    expect(() =>\n      calculateDatetime({ date: \"2025-07-13T12:00:00Z\", expression: \"bad expr\" }),\n    ).toThrow(\"Invalid date expression: bad\");\n  });\n});\n"
  },
  {
    "path": "plugins/template-function-timestamp/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-uuid/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-uuid\",\n  \"displayName\": \"UUID Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template functions for generating UUIDs\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  },\n  \"dependencies\": {\n    \"uuid\": \"^11.1.0\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-uuid/src/index.ts",
    "content": "import type { CallTemplateFunctionArgs, Context, PluginDefinition } from \"@yaakapp/api\";\nimport { v1, v3, v4, v5, v6, v7 } from \"uuid\";\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: [\n    {\n      name: \"uuid.v1\",\n      description: \"Generate a UUID V1\",\n      args: [],\n      async onRender(): Promise<string | null> {\n        return v1();\n      },\n    },\n    {\n      name: \"uuid.v3\",\n      description: \"Generate a UUID V3\",\n      args: [\n        { type: \"text\", name: \"name\", label: \"Name\" },\n        {\n          type: \"text\",\n          name: \"namespace\",\n          label: \"Namespace UUID\",\n          description: \"A valid UUID to use as the namespace\",\n          placeholder: \"24ced880-3bf4-11f0-8329-cd053d577f0e\",\n        },\n      ],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        return v3(String(args.values.name), String(args.values.namespace));\n      },\n    },\n    {\n      name: \"uuid.v4\",\n      description: \"Generate a UUID V4\",\n      args: [],\n      async onRender(): Promise<string | null> {\n        return v4();\n      },\n    },\n    {\n      name: \"uuid.v5\",\n      description: \"Generate a UUID V5\",\n      args: [\n        { type: \"text\", name: \"name\", label: \"Name\" },\n        { type: \"text\", name: \"namespace\", label: \"Namespace\" },\n      ],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        return v5(String(args.values.name), String(args.values.namespace));\n      },\n    },\n    {\n      name: \"uuid.v6\",\n      description: \"Generate a UUID V6\",\n      args: [\n        {\n          type: \"text\",\n          name: \"timestamp\",\n          label: \"Timestamp\",\n          optional: true,\n          description: \"Can be any format that can be parsed by JavaScript new Date(...)\",\n          placeholder: \"2025-05-28T11:15:00Z\",\n        },\n      ],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        return v6({ msecs: new Date(String(args.values.timestamp)).getTime() });\n      },\n    },\n    {\n      name: \"uuid.v7\",\n      description: \"Generate a UUID V7\",\n      args: [],\n      async onRender(): Promise<string | null> {\n        return v7();\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "plugins/template-function-uuid/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/template-function-xml/package.json",
    "content": "{\n  \"name\": \"@yaak/template-function-xml\",\n  \"displayName\": \"XML Template Functions\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Template functions for working with XML data\",\n  \"main\": \"build/index.js\",\n  \"types\": \"src/index.ts\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  },\n  \"dependencies\": {\n    \"@xmldom/xmldom\": \"^0.9.8\",\n    \"xpath\": \"^0.0.34\"\n  }\n}\n"
  },
  {
    "path": "plugins/template-function-xml/src/index.ts",
    "content": "/* oxlint-disable no-base-to-string */\nimport { DOMParser } from \"@xmldom/xmldom\";\nimport type { CallTemplateFunctionArgs, Context, PluginDefinition } from \"@yaakapp/api\";\nimport xpath from \"xpath\";\n\nconst RETURN_FIRST = \"first\";\nconst RETURN_ALL = \"all\";\nconst RETURN_JOIN = \"join\";\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: [\n    {\n      name: \"xml.xpath\",\n      description: \"Filter XML-formatted text using XPath syntax\",\n      previewArgs: [\"query\"],\n      args: [\n        {\n          type: \"text\",\n          name: \"input\",\n          label: \"Input\",\n          multiLine: true,\n          placeholder: \"<foo></foo>\",\n        },\n        {\n          type: \"h_stack\",\n          inputs: [\n            {\n              type: \"select\",\n              name: \"result\",\n              label: \"Return Format\",\n              defaultValue: RETURN_FIRST,\n              options: [\n                { label: \"First result\", value: RETURN_FIRST },\n                { label: \"All results\", value: RETURN_ALL },\n                { label: \"Join with separator\", value: RETURN_JOIN },\n              ],\n            },\n            {\n              name: \"join\",\n              type: \"text\",\n              label: \"Separator\",\n              optional: true,\n              defaultValue: \", \",\n              dynamic(_ctx, args) {\n                return { hidden: args.values.result !== RETURN_JOIN };\n              },\n            },\n          ],\n        },\n        { type: \"text\", name: \"query\", label: \"Query\", placeholder: \"//foo\" },\n      ],\n      async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {\n        try {\n          const result = (args.values.result || RETURN_FIRST) as XPathResult;\n          const join = args.values.join == null ? null : String(args.values.join);\n          return filterXPath(String(args.values.input), String(args.values.query), result, join);\n        } catch {\n          return null;\n        }\n      },\n    },\n  ],\n};\n\nexport type XPathResult = \"first\" | \"join\" | \"all\";\nexport function filterXPath(\n  body: string,\n  path: string,\n  result: XPathResult,\n  join: string | null,\n): string {\n  // oxlint-disable-next-line no-explicit-any\n  const doc: any = new DOMParser().parseFromString(body, \"text/xml\");\n  const items = xpath.select(path, doc, false);\n\n  if (!Array.isArray(items)) {\n    return String(items);\n  }\n  if (!Array.isArray(items) || result === \"first\") {\n    return items[0] != null ? String(items[0].firstChild ?? \"\") : \"\";\n  }\n  if (result === \"join\") {\n    return items.map((item) => String(item.firstChild ?? \"\")).join(join ?? \"\");\n  }\n  // Not sure what cases this happens in (?)\n  return String(items);\n}\n"
  },
  {
    "path": "plugins/template-function-xml/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins/themes-yaak/package.json",
    "content": "{\n  \"name\": \"@yaak/themes-yaak\",\n  \"displayName\": \"Yaak Themes\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"description\": \"Default themes for Yaak\",\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  }\n}\n"
  },
  {
    "path": "plugins/themes-yaak/src/index.ts",
    "content": "import type { PluginDefinition } from \"@yaakapp/api\";\nimport { andromeda } from \"./themes/andromeda\";\nimport { atomOneDark } from \"./themes/atom-one-dark\";\nimport { ayuDark, ayuLight, ayuMirage } from \"./themes/ayu\";\nimport { blulocoDark, blulocoLight } from \"./themes/bluloco\";\nimport {\n  catppuccinFrappe,\n  catppuccinLatte,\n  catppuccinMacchiato,\n  catppuccinMocha,\n} from \"./themes/catppuccin\";\nimport { cobalt2 } from \"./themes/cobalt2\";\nimport { dracula } from \"./themes/dracula\";\nimport { everforestDark, everforestLight } from \"./themes/everforest\";\nimport { fleetDark, fleetDarkPurple, fleetLight } from \"./themes/fleet\";\nimport { githubDark, githubLight } from \"./themes/github\";\nimport { githubDarkDimmed } from \"./themes/github-dimmed\";\nimport { gruvbox } from \"./themes/gruvbox\";\n// Yaak themes\nimport { highContrast, highContrastDark } from \"./themes/high-contrast\";\nimport { horizon } from \"./themes/horizon\";\nimport { hotdogStand } from \"./themes/hotdog-stand\";\nimport { materialDarker } from \"./themes/material-darker\";\nimport { materialOcean } from \"./themes/material-ocean\";\nimport { materialPalenight } from \"./themes/material-palenight\";\nimport {\n  monokaiPro,\n  monokaiProClassic,\n  monokaiProMachine,\n  monokaiProOctagon,\n  monokaiProRistretto,\n  monokaiProSpectrum,\n} from \"./themes/monokai-pro\";\nimport { moonlight } from \"./themes/moonlight\";\nimport { lightOwl, nightOwl } from \"./themes/night-owl\";\nimport { noctisAzureus } from \"./themes/noctis\";\nimport { nord, nordLight, nordLightBrighter } from \"./themes/nord\";\n// VSCode themes\nimport { oneDarkPro } from \"./themes/one-dark-pro\";\nimport { pandaSyntax } from \"./themes/panda\";\nimport { relaxing } from \"./themes/relaxing\";\nimport { rosePine, rosePineDawn, rosePineMoon } from \"./themes/rose-pine\";\nimport { shadesOfPurple, shadesOfPurpleSuperDark } from \"./themes/shades-of-purple\";\nimport { slackAubergine } from \"./themes/slack\";\nimport { solarizedDark, solarizedLight } from \"./themes/solarized\";\nimport { synthwave84 } from \"./themes/synthwave-84\";\nimport { tokyoNight, tokyoNightDay, tokyoNightStorm } from \"./themes/tokyo-night\";\nimport { triangle } from \"./themes/triangle\";\nimport { vitesseDark, vitesseLight } from \"./themes/vitesse\";\nimport { winterIsComing } from \"./themes/winter-is-coming\";\n\nexport const plugin: PluginDefinition = {\n  themes: [\n    andromeda,\n    atomOneDark,\n    ayuDark,\n    ayuLight,\n    ayuMirage,\n    blulocoDark,\n    blulocoLight,\n    catppuccinFrappe,\n    catppuccinLatte,\n    catppuccinMacchiato,\n    catppuccinMocha,\n    cobalt2,\n    dracula,\n    everforestDark,\n    everforestLight,\n    fleetDark,\n    fleetDarkPurple,\n    fleetLight,\n    githubDark,\n    githubDarkDimmed,\n    githubLight,\n    gruvbox,\n    highContrast,\n    highContrastDark,\n    horizon,\n    hotdogStand,\n    lightOwl,\n    materialDarker,\n    materialOcean,\n    materialPalenight,\n    monokaiPro,\n    monokaiProClassic,\n    monokaiProMachine,\n    monokaiProOctagon,\n    monokaiProRistretto,\n    monokaiProSpectrum,\n    moonlight,\n    nightOwl,\n    noctisAzureus,\n    nord,\n    nordLight,\n    nordLightBrighter,\n    oneDarkPro,\n    pandaSyntax,\n    relaxing,\n    rosePine,\n    rosePineDawn,\n    rosePineMoon,\n    shadesOfPurple,\n    shadesOfPurpleSuperDark,\n    slackAubergine,\n    solarizedDark,\n    solarizedLight,\n    synthwave84,\n    tokyoNight,\n    tokyoNightDay,\n    tokyoNightStorm,\n    triangle,\n    vitesseDark,\n    vitesseLight,\n    winterIsComing,\n  ],\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/andromeda.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const andromeda: Theme = {\n  id: \"andromeda\",\n  label: \"Andromeda\",\n  dark: true,\n  base: {\n    surface: \"hsl(251, 25%, 15%)\",\n    surfaceHighlight: \"hsl(251, 22%, 20%)\",\n    text: \"hsl(220, 10%, 85%)\",\n    textSubtle: \"hsl(220, 8%, 60%)\",\n    textSubtlest: \"hsl(220, 6%, 45%)\",\n    primary: \"hsl(293, 75%, 68%)\",\n    secondary: \"hsl(220, 8%, 60%)\",\n    info: \"hsl(180, 60%, 60%)\",\n    success: \"hsl(85, 60%, 55%)\",\n    notice: \"hsl(38, 100%, 65%)\",\n    warning: \"hsl(25, 95%, 60%)\",\n    danger: \"hsl(358, 80%, 60%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(251, 25%, 12%)\",\n    },\n    sidebar: {\n      surface: \"hsl(251, 23%, 13%)\",\n      border: \"hsl(251, 20%, 18%)\",\n    },\n    appHeader: {\n      surface: \"hsl(251, 25%, 11%)\",\n      border: \"hsl(251, 20%, 16%)\",\n    },\n    responsePane: {\n      surface: \"hsl(251, 23%, 13%)\",\n      border: \"hsl(251, 20%, 18%)\",\n    },\n    button: {\n      primary: \"hsl(293, 75%, 61%)\",\n      secondary: \"hsl(220, 8%, 53%)\",\n      info: \"hsl(180, 60%, 53%)\",\n      success: \"hsl(85, 60%, 48%)\",\n      notice: \"hsl(38, 100%, 58%)\",\n      warning: \"hsl(25, 95%, 53%)\",\n      danger: \"hsl(358, 80%, 53%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/atom-one-dark.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const atomOneDark: Theme = {\n  id: \"atom-one-dark\",\n  label: \"Atom One Dark\",\n  dark: true,\n  base: {\n    surface: \"hsl(220, 13%, 18%)\",\n    surfaceHighlight: \"hsl(219, 13%, 22%)\",\n    text: \"hsl(219, 14%, 71%)\",\n    textSubtle: \"hsl(220, 9%, 55%)\",\n    textSubtlest: \"hsl(220, 8%, 45%)\",\n    primary: \"hsl(286, 60%, 67%)\",\n    secondary: \"hsl(220, 9%, 55%)\",\n    info: \"hsl(207, 82%, 66%)\",\n    success: \"hsl(95, 38%, 62%)\",\n    notice: \"hsl(39, 67%, 69%)\",\n    warning: \"hsl(29, 54%, 61%)\",\n    danger: \"hsl(355, 65%, 65%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(220, 13%, 14%)\",\n    },\n    sidebar: {\n      surface: \"hsl(220, 13%, 16%)\",\n      border: \"hsl(220, 13%, 20%)\",\n    },\n    appHeader: {\n      surface: \"hsl(220, 13%, 12%)\",\n      border: \"hsl(220, 13%, 18%)\",\n    },\n    responsePane: {\n      surface: \"hsl(220, 13%, 16%)\",\n      border: \"hsl(220, 13%, 20%)\",\n    },\n    button: {\n      primary: \"hsl(286, 60%, 60%)\",\n      secondary: \"hsl(220, 9%, 48%)\",\n      info: \"hsl(207, 82%, 59%)\",\n      success: \"hsl(95, 38%, 55%)\",\n      notice: \"hsl(39, 67%, 62%)\",\n      warning: \"hsl(29, 54%, 54%)\",\n      danger: \"hsl(355, 65%, 58%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/ayu.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const ayuDark: Theme = {\n  id: \"ayu-dark\",\n  label: \"Ayu Dark\",\n  dark: true,\n  base: {\n    surface: \"hsl(220, 25%, 10%)\",\n    surfaceHighlight: \"hsl(220, 20%, 15%)\",\n    text: \"hsl(210, 22%, 78%)\",\n    textSubtle: \"hsl(40, 13%, 50%)\",\n    textSubtlest: \"hsl(220, 10%, 40%)\",\n    primary: \"hsl(38, 100%, 56%)\",\n    secondary: \"hsl(210, 15%, 55%)\",\n    info: \"hsl(200, 80%, 60%)\",\n    success: \"hsl(100, 75%, 60%)\",\n    notice: \"hsl(38, 100%, 56%)\",\n    warning: \"hsl(25, 100%, 60%)\",\n    danger: \"hsl(345, 80%, 60%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(220, 25%, 8%)\",\n    },\n    sidebar: {\n      surface: \"hsl(220, 22%, 12%)\",\n      border: \"hsl(220, 20%, 16%)\",\n    },\n    appHeader: {\n      surface: \"hsl(220, 25%, 7%)\",\n      border: \"hsl(220, 20%, 13%)\",\n    },\n    responsePane: {\n      surface: \"hsl(220, 22%, 12%)\",\n      border: \"hsl(220, 20%, 16%)\",\n    },\n    button: {\n      primary: \"hsl(38, 100%, 50%)\",\n      secondary: \"hsl(210, 15%, 48%)\",\n      info: \"hsl(200, 80%, 53%)\",\n      success: \"hsl(100, 75%, 53%)\",\n      notice: \"hsl(38, 100%, 50%)\",\n      warning: \"hsl(25, 100%, 53%)\",\n      danger: \"hsl(345, 80%, 53%)\",\n    },\n  },\n};\n\nexport const ayuMirage: Theme = {\n  id: \"ayu-mirage\",\n  label: \"Ayu Mirage\",\n  dark: true,\n  base: {\n    surface: \"hsl(226, 23%, 17%)\",\n    surfaceHighlight: \"hsl(226, 20%, 22%)\",\n    text: \"hsl(212, 15%, 81%)\",\n    textSubtle: \"hsl(212, 12%, 55%)\",\n    textSubtlest: \"hsl(212, 10%, 45%)\",\n    primary: \"hsl(38, 100%, 67%)\",\n    secondary: \"hsl(212, 12%, 55%)\",\n    info: \"hsl(200, 80%, 70%)\",\n    success: \"hsl(100, 50%, 68%)\",\n    notice: \"hsl(38, 100%, 67%)\",\n    warning: \"hsl(25, 100%, 70%)\",\n    danger: \"hsl(345, 80%, 70%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(226, 23%, 14%)\",\n    },\n    sidebar: {\n      surface: \"hsl(226, 22%, 15%)\",\n      border: \"hsl(226, 20%, 20%)\",\n    },\n    appHeader: {\n      surface: \"hsl(226, 23%, 12%)\",\n      border: \"hsl(226, 20%, 17%)\",\n    },\n    responsePane: {\n      surface: \"hsl(226, 22%, 15%)\",\n      border: \"hsl(226, 20%, 20%)\",\n    },\n    button: {\n      primary: \"hsl(38, 100%, 60%)\",\n      info: \"hsl(200, 80%, 63%)\",\n      success: \"hsl(100, 50%, 61%)\",\n      notice: \"hsl(38, 100%, 60%)\",\n      warning: \"hsl(25, 100%, 63%)\",\n      danger: \"hsl(345, 80%, 63%)\",\n    },\n  },\n};\n\nexport const ayuLight: Theme = {\n  id: \"ayu-light\",\n  label: \"Ayu Light\",\n  dark: false,\n  base: {\n    surface: \"hsl(40, 22%, 97%)\",\n    surfaceHighlight: \"hsl(40, 20%, 93%)\",\n    text: \"hsl(214, 10%, 35%)\",\n    textSubtle: \"hsl(214, 8%, 50%)\",\n    textSubtlest: \"hsl(214, 6%, 60%)\",\n    primary: \"hsl(35, 100%, 45%)\",\n    secondary: \"hsl(214, 8%, 50%)\",\n    info: \"hsl(200, 75%, 45%)\",\n    success: \"hsl(100, 60%, 40%)\",\n    notice: \"hsl(35, 100%, 45%)\",\n    warning: \"hsl(22, 100%, 50%)\",\n    danger: \"hsl(345, 70%, 55%)\",\n  },\n  components: {\n    sidebar: {\n      surface: \"hsl(40, 20%, 95%)\",\n      border: \"hsl(40, 15%, 90%)\",\n    },\n    appHeader: {\n      surface: \"hsl(40, 20%, 93%)\",\n      border: \"hsl(40, 15%, 88%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/bluloco.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const blulocoDark: Theme = {\n  id: \"bluloco-dark\",\n  label: \"Bluloco Dark\",\n  dark: true,\n  base: {\n    surface: \"hsl(230, 20%, 14%)\",\n    surfaceHighlight: \"hsl(230, 17%, 19%)\",\n    text: \"hsl(220, 15%, 80%)\",\n    textSubtle: \"hsl(220, 10%, 55%)\",\n    textSubtlest: \"hsl(220, 8%, 42%)\",\n    primary: \"hsl(218, 85%, 65%)\",\n    secondary: \"hsl(220, 10%, 55%)\",\n    info: \"hsl(218, 85%, 65%)\",\n    success: \"hsl(95, 55%, 55%)\",\n    notice: \"hsl(37, 90%, 60%)\",\n    warning: \"hsl(22, 85%, 55%)\",\n    danger: \"hsl(355, 75%, 60%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(230, 20%, 11%)\",\n    },\n    sidebar: {\n      surface: \"hsl(230, 18%, 12%)\",\n      border: \"hsl(230, 16%, 17%)\",\n    },\n    appHeader: {\n      surface: \"hsl(230, 20%, 10%)\",\n      border: \"hsl(230, 16%, 15%)\",\n    },\n    responsePane: {\n      surface: \"hsl(230, 18%, 12%)\",\n      border: \"hsl(230, 16%, 17%)\",\n    },\n    button: {\n      primary: \"hsl(218, 85%, 58%)\",\n      secondary: \"hsl(220, 10%, 48%)\",\n      info: \"hsl(218, 85%, 58%)\",\n      success: \"hsl(95, 55%, 48%)\",\n      notice: \"hsl(37, 90%, 53%)\",\n      warning: \"hsl(22, 85%, 48%)\",\n      danger: \"hsl(355, 75%, 53%)\",\n    },\n  },\n};\n\nexport const blulocoLight: Theme = {\n  id: \"bluloco-light\",\n  label: \"Bluloco Light\",\n  dark: false,\n  base: {\n    surface: \"hsl(0, 0%, 98%)\",\n    surfaceHighlight: \"hsl(220, 15%, 94%)\",\n    text: \"hsl(228, 18%, 30%)\",\n    textSubtle: \"hsl(228, 10%, 48%)\",\n    textSubtlest: \"hsl(228, 8%, 58%)\",\n    primary: \"hsl(218, 80%, 48%)\",\n    secondary: \"hsl(228, 10%, 48%)\",\n    info: \"hsl(218, 80%, 48%)\",\n    success: \"hsl(138, 55%, 40%)\",\n    notice: \"hsl(35, 85%, 45%)\",\n    warning: \"hsl(22, 80%, 48%)\",\n    danger: \"hsl(355, 70%, 48%)\",\n  },\n  components: {\n    sidebar: {\n      surface: \"hsl(220, 15%, 96%)\",\n      border: \"hsl(220, 12%, 90%)\",\n    },\n    appHeader: {\n      surface: \"hsl(220, 15%, 94%)\",\n      border: \"hsl(220, 12%, 88%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/catppuccin.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const catppuccinFrappe: Theme = {\n  id: \"catppuccin-frappe\",\n  label: \"Catppuccin Frappé\",\n  dark: true,\n  base: {\n    surface: \"hsl(231,19%,20%)\",\n    text: \"hsl(227,70%,87%)\",\n    textSubtle: \"hsl(228,29%,73%)\",\n    textSubtlest: \"hsl(227,17%,58%)\",\n    primary: \"hsl(277,59%,76%)\",\n    secondary: \"hsl(228,39%,80%)\",\n    info: \"hsl(222,74%,74%)\",\n    success: \"hsl(96,44%,68%)\",\n    notice: \"hsl(40,62%,73%)\",\n    warning: \"hsl(20,79%,70%)\",\n    danger: \"hsl(359,68%,71%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(240,21%,12%)\",\n    },\n    sidebar: {\n      surface: \"hsl(229,19%,23%)\",\n      border: \"hsl(229,19%,27%)\",\n    },\n    appHeader: {\n      surface: \"hsl(229,20%,17%)\",\n      border: \"hsl(229,20%,25%)\",\n    },\n    responsePane: {\n      surface: \"hsl(229,19%,23%)\",\n      border: \"hsl(229,19%,27%)\",\n    },\n    button: {\n      primary: \"hsl(277,59%,68%)\",\n      secondary: \"hsl(228,39%,72%)\",\n      info: \"hsl(222,74%,67%)\",\n      success: \"hsl(96,44%,61%)\",\n      notice: \"hsl(40,62%,66%)\",\n      warning: \"hsl(20,79%,63%)\",\n      danger: \"hsl(359,68%,64%)\",\n    },\n  },\n};\n\nexport const catppuccinMacchiato: Theme = {\n  id: \"catppuccin-macchiato\",\n  label: \"Catppuccin Macchiato\",\n  dark: true,\n  base: {\n    surface: \"hsl(233,23%,15%)\",\n    text: \"hsl(227,68%,88%)\",\n    textSubtle: \"hsl(227,27%,72%)\",\n    textSubtlest: \"hsl(228,15%,57%)\",\n    primary: \"hsl(267,83%,80%)\",\n    secondary: \"hsl(228,39%,80%)\",\n    info: \"hsl(220,83%,75%)\",\n    success: \"hsl(105,48%,72%)\",\n    notice: \"hsl(40,70%,78%)\",\n    warning: \"hsl(21,86%,73%)\",\n    danger: \"hsl(351,74%,73%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(240,21%,12%)\",\n    },\n    sidebar: {\n      surface: \"hsl(232,23%,18%)\",\n      border: \"hsl(231,23%,22%)\",\n    },\n    appHeader: {\n      surface: \"hsl(236,23%,12%)\",\n      border: \"hsl(236,23%,21%)\",\n    },\n    responsePane: {\n      surface: \"hsl(232,23%,18%)\",\n      border: \"hsl(231,23%,22%)\",\n    },\n    button: {\n      primary: \"hsl(267,82%,72%)\",\n      secondary: \"hsl(228,39%,72%)\",\n      info: \"hsl(220,83%,68%)\",\n      success: \"hsl(105,48%,65%)\",\n      notice: \"hsl(40,70%,70%)\",\n      warning: \"hsl(21,86%,66%)\",\n      danger: \"hsl(351,74%,66%)\",\n    },\n  },\n};\n\nexport const catppuccinMocha: Theme = {\n  id: \"catppuccin-mocha\",\n  label: \"Catppuccin Mocha\",\n  dark: true,\n  base: {\n    surface: \"hsl(240,21%,12%)\",\n    text: \"hsl(226,64%,88%)\",\n    textSubtle: \"hsl(228,24%,72%)\",\n    textSubtlest: \"hsl(230,13%,55%)\",\n    primary: \"hsl(267,83%,80%)\",\n    secondary: \"hsl(227,35%,80%)\",\n    info: \"hsl(217,92%,76%)\",\n    success: \"hsl(115,54%,76%)\",\n    notice: \"hsl(41,86%,83%)\",\n    warning: \"hsl(23,92%,75%)\",\n    danger: \"hsl(343,81%,75%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(240,21%,12%)\",\n    },\n    sidebar: {\n      surface: \"hsl(240,21%,15%)\",\n      border: \"hsl(240,21%,19%)\",\n    },\n    appHeader: {\n      surface: \"hsl(240,23%,9%)\",\n      border: \"hsl(240,22%,18%)\",\n    },\n    responsePane: {\n      surface: \"hsl(240,21%,15%)\",\n      border: \"hsl(240,21%,19%)\",\n    },\n    button: {\n      primary: \"hsl(267,67%,65%)\",\n      secondary: \"hsl(227,28%,64%)\",\n      info: \"hsl(217,74%,61%)\",\n      success: \"hsl(115,43%,61%)\",\n      notice: \"hsl(41,69%,66%)\",\n      warning: \"hsl(23,74%,60%)\",\n      danger: \"hsl(343,65%,60%)\",\n    },\n  },\n};\n\nexport const catppuccinLatte: Theme = {\n  id: \"catppuccin-latte\",\n  label: \"Catppuccin Latte\",\n  dark: false,\n  base: {\n    surface: \"hsl(220,23%,95%)\",\n    text: \"hsl(234,16%,35%)\",\n    textSubtle: \"hsl(233,10%,47%)\",\n    textSubtlest: \"hsl(231,10%,59%)\",\n    primary: \"hsl(266,85%,58%)\",\n    secondary: \"hsl(233,10%,47%)\",\n    info: \"hsl(231,97%,72%)\",\n    success: \"hsl(183,74%,35%)\",\n    notice: \"hsl(35,77%,49%)\",\n    warning: \"hsl(22,99%,52%)\",\n    danger: \"hsl(355,76%,59%)\",\n  },\n  components: {\n    sidebar: {\n      surface: \"hsl(220,22%,92%)\",\n      border: \"hsl(220,22%,87%)\",\n    },\n    appHeader: {\n      surface: \"hsl(220,21%,89%)\",\n      border: \"hsl(220,22%,87%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/cobalt2.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const cobalt2: Theme = {\n  id: \"cobalt2\",\n  label: \"Cobalt2\",\n  dark: true,\n  base: {\n    surface: \"#193549\",\n    surfaceHighlight: \"#1f4662\",\n    text: \"#d2e1f1\",\n    textSubtle: \"#709ac8\",\n    textSubtlest: \"#55749e\",\n    primary: \"#ffc600\",\n    secondary: \"#819fc3\",\n    info: \"#0088FF\",\n    success: \"#3AD900\",\n    notice: \"#FFEE80\",\n    warning: \"#FF9D00\",\n    danger: \"#FF628C\",\n  },\n  components: {\n    sidebar: {\n      surface: \"#13283a\",\n      border: \"#102332\",\n    },\n    input: {\n      border: \"#1f4561\",\n    },\n    appHeader: {\n      surface: \"#13283a\",\n      border: \"#112636\",\n    },\n    responsePane: {\n      surface: \"#13283a\",\n      border: \"#112636\",\n    },\n    button: {\n      primary: \"#ffc600\",\n      secondary: \"#709ac8\",\n      info: \"#0088FF\",\n      success: \"#3AD900\",\n      notice: \"#ecdc6a\",\n      warning: \"#FF9D00\",\n      danger: \"#FF628C\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/dracula.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const dracula: Theme = {\n  id: \"dracula\",\n  label: \"Dracula\",\n  dark: true,\n  base: {\n    surface: \"hsl(231,15%,18%)\",\n    surfaceHighlight: \"hsl(230,15%,24%)\",\n    text: \"hsl(60,30%,96%)\",\n    textSubtle: \"hsl(232,14%,65%)\",\n    textSubtlest: \"hsl(232,14%,50%)\",\n    primary: \"hsl(265,89%,78%)\",\n    secondary: \"hsl(225,27%,51%)\",\n    info: \"hsl(191,97%,77%)\",\n    success: \"hsl(135,94%,65%)\",\n    notice: \"hsl(65,92%,76%)\",\n    warning: \"hsl(31,100%,71%)\",\n    danger: \"hsl(0,100%,67%)\",\n  },\n  components: {\n    sidebar: {\n      backdrop: \"hsl(230,15%,24%)\",\n    },\n    appHeader: {\n      backdrop: \"hsl(235,14%,15%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/everforest.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const everforestDark: Theme = {\n  id: \"everforest-dark\",\n  label: \"Everforest Dark\",\n  dark: true,\n  base: {\n    surface: \"hsl(150, 8%, 18%)\",\n    surfaceHighlight: \"hsl(150, 7%, 22%)\",\n    text: \"hsl(45, 30%, 78%)\",\n    textSubtle: \"hsl(145, 8%, 55%)\",\n    textSubtlest: \"hsl(145, 6%, 42%)\",\n    primary: \"hsl(142, 35%, 60%)\",\n    secondary: \"hsl(145, 8%, 55%)\",\n    info: \"hsl(200, 35%, 65%)\",\n    success: \"hsl(142, 35%, 60%)\",\n    notice: \"hsl(46, 55%, 68%)\",\n    warning: \"hsl(24, 55%, 65%)\",\n    danger: \"hsl(358, 50%, 68%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(150, 8%, 15%)\",\n    },\n    sidebar: {\n      surface: \"hsl(150, 7%, 16%)\",\n      border: \"hsl(150, 6%, 20%)\",\n    },\n    appHeader: {\n      surface: \"hsl(150, 8%, 14%)\",\n      border: \"hsl(150, 6%, 18%)\",\n    },\n    responsePane: {\n      surface: \"hsl(150, 7%, 16%)\",\n      border: \"hsl(150, 6%, 20%)\",\n    },\n    button: {\n      primary: \"hsl(142, 35%, 53%)\",\n      secondary: \"hsl(145, 8%, 48%)\",\n      info: \"hsl(200, 35%, 58%)\",\n      success: \"hsl(142, 35%, 53%)\",\n      notice: \"hsl(46, 55%, 61%)\",\n      warning: \"hsl(24, 55%, 58%)\",\n      danger: \"hsl(358, 50%, 61%)\",\n    },\n  },\n};\n\nexport const everforestLight: Theme = {\n  id: \"everforest-light\",\n  label: \"Everforest Light\",\n  dark: false,\n  base: {\n    surface: \"hsl(40, 32%, 93%)\",\n    surfaceHighlight: \"hsl(40, 28%, 89%)\",\n    text: \"hsl(135, 8%, 35%)\",\n    textSubtle: \"hsl(135, 6%, 45%)\",\n    textSubtlest: \"hsl(135, 4%, 55%)\",\n    primary: \"hsl(128, 30%, 45%)\",\n    secondary: \"hsl(135, 6%, 45%)\",\n    info: \"hsl(200, 35%, 45%)\",\n    success: \"hsl(128, 30%, 45%)\",\n    notice: \"hsl(45, 70%, 40%)\",\n    warning: \"hsl(22, 60%, 48%)\",\n    danger: \"hsl(355, 55%, 50%)\",\n  },\n  components: {\n    sidebar: {\n      surface: \"hsl(40, 30%, 91%)\",\n      border: \"hsl(40, 25%, 86%)\",\n    },\n    appHeader: {\n      surface: \"hsl(40, 30%, 89%)\",\n      border: \"hsl(40, 25%, 84%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/fleet.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const fleetLight: Theme = {\n  id: \"fleet-light\",\n  label: \"Fleet Light\",\n  dark: false,\n  base: {\n    surface: \"#FFFFFF\",\n    surfaceHighlight: \"#F8F8F9\",\n    surfaceActive: \"#EEEFF0\",\n    border: \"#18191B33\",\n    text: \"#090909\",\n    textSubtle: \"#6E747B\",\n    textSubtlest: \"#898E94\",\n    primary: \"#1D61BA\",\n    secondary: \"#6E747B\",\n    info: \"#4B8DEC\",\n    success: \"#169068\",\n    notice: \"#B07203\",\n    warning: \"#B07203\",\n    danger: \"#E1465E\",\n  },\n  components: {\n    sidebar: {\n      surface: \"#EEEFF0\",\n      border: \"#18191B33\",\n    },\n    appHeader: {\n      surface: \"#EEEFF0\",\n      border: \"#18191B33\",\n    },\n    responsePane: {\n      surface: \"#FFFFFF\",\n      border: \"#18191B33\",\n    },\n    dialog: {\n      surface: \"#FFFFFF\",\n      border: \"#18191B33\",\n    },\n    button: {\n      surface: \"#F8F8F9\",\n      text: \"#090909\",\n      primary: \"#2A7DEB\",\n      secondary: \"#6E747B\",\n      info: \"#4B8DEC\",\n      success: \"#169068\",\n      notice: \"#B07203\",\n      warning: \"#B07203\",\n      danger: \"#E1465E\",\n    },\n    editor: {\n      primary: \"#5511BF\",\n      secondary: \"#A31D8D\",\n      info: \"#14646E\",\n      success: \"#086E14\",\n      notice: \"#616605\",\n      warning: \"#747576\",\n      danger: \"#1749BD\",\n    },\n  },\n};\n\nexport const fleetDarkPurple: Theme = {\n  id: \"fleet-dark-purple\",\n  label: \"Fleet Dark Purple\",\n  dark: true,\n  base: {\n    surface: \"#1C1827\",\n    surfaceHighlight: \"#262136\",\n    surfaceActive: \"#3E3852\",\n    border: \"#3E3852\",\n    text: \"#E0E1E4\",\n    textSubtle: \"#E0E1E480\",\n    textSubtlest: \"#E0E1E44D\",\n    primary: \"#B174D9\",\n    secondary: \"#E0E1E480\",\n    info: \"#4B8DEC\",\n    success: \"#169068\",\n    notice: \"#B07203\",\n    warning: \"#B07203\",\n    danger: \"#E1465E\",\n  },\n  components: {\n    appHeader: {\n      surface: \"#13101B\",\n      border: \"#3E3852\",\n    },\n    responsePane: {\n      surface: \"#1C1827\",\n      border: \"#3E3852\",\n    },\n    dialog: {\n      surface: \"#262136\",\n      border: \"#3E3852\",\n    },\n    button: {\n      surface: \"#262136\",\n      text: \"#E0E1E4\",\n      primary: \"#A660D4\",\n      secondary: \"#E0E1E480\",\n      info: \"#4B8DEC\",\n      success: \"#169068\",\n      notice: \"#B07203\",\n      warning: \"#B07203\",\n      danger: \"#E1465E\",\n    },\n    editor: {\n      primary: \"#C7A65D\",\n      secondary: \"#93A6F5\",\n      info: \"#E09B70\",\n      success: \"#62A362\",\n      notice: \"#85A658\",\n      warning: \"#7e7d86\",\n      danger: \"#4DACF0\",\n    },\n  },\n};\n\nexport const fleetDark: Theme = {\n  id: \"fleet-dark\",\n  label: \"Fleet Dark\",\n  dark: true,\n  base: {\n    surface: \"#18191B\",\n    surfaceHighlight: \"#252629\",\n    surfaceActive: \"#3E4147\",\n    border: \"#3E4147\",\n    text: \"#E0E1E4\",\n    textSubtle: \"#898E94\",\n    textSubtlest: \"#646B71\",\n    primary: \"#4B8DEC\",\n    secondary: \"#898E94\",\n    info: \"#4B8DEC\",\n    success: \"#169068\",\n    notice: \"#B07203\",\n    warning: \"#B07203\",\n    danger: \"#E1465E\",\n  },\n  components: {\n    appHeader: {\n      surface: \"#090909\",\n      border: \"#3E4147\",\n    },\n    responsePane: {\n      surface: \"#18191B\",\n      border: \"#3E4147\",\n    },\n    dialog: {\n      surface: \"#252629\",\n      border: \"#3E4147\",\n    },\n    button: {\n      surface: \"#252629\",\n      text: \"#E0E1E4\",\n      primary: \"#2A7DEB\",\n      secondary: \"#898E94\",\n      info: \"#4B8DEC\",\n      success: \"#169068\",\n      notice: \"#B07203\",\n      warning: \"#B07203\",\n      danger: \"#E1465E\",\n    },\n    editor: {\n      primary: \"#EBC88D\",\n      secondary: \"#AF9CFF\",\n      info: \"#82D2CE\",\n      success: \"#A8C5A0\",\n      notice: \"#C7A65D\",\n      warning: \"#909194\",\n      danger: \"#87C3FF\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/github-dimmed.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const githubDarkDimmed: Theme = {\n  id: \"github-dark-dimmed\",\n  label: \"GitHub Dark Dimmed\",\n  dark: true,\n  base: {\n    surface: \"hsl(215, 15%, 16%)\",\n    surfaceHighlight: \"hsl(215, 13%, 20%)\",\n    text: \"hsl(212, 15%, 78%)\",\n    textSubtle: \"hsl(212, 10%, 55%)\",\n    textSubtlest: \"hsl(212, 8%, 42%)\",\n    primary: \"hsl(212, 80%, 65%)\",\n    secondary: \"hsl(212, 10%, 55%)\",\n    info: \"hsl(212, 80%, 65%)\",\n    success: \"hsl(140, 50%, 50%)\",\n    notice: \"hsl(42, 75%, 55%)\",\n    warning: \"hsl(27, 80%, 55%)\",\n    danger: \"hsl(355, 70%, 55%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(215, 15%, 13%)\",\n    },\n    sidebar: {\n      surface: \"hsl(215, 14%, 14%)\",\n      border: \"hsl(215, 12%, 19%)\",\n    },\n    appHeader: {\n      surface: \"hsl(215, 15%, 12%)\",\n      border: \"hsl(215, 12%, 17%)\",\n    },\n    responsePane: {\n      surface: \"hsl(215, 14%, 14%)\",\n      border: \"hsl(215, 12%, 19%)\",\n    },\n    button: {\n      primary: \"hsl(212, 80%, 58%)\",\n      info: \"hsl(212, 80%, 58%)\",\n      success: \"hsl(140, 50%, 45%)\",\n      notice: \"hsl(42, 75%, 48%)\",\n      warning: \"hsl(27, 80%, 48%)\",\n      danger: \"hsl(355, 70%, 48%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/github.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const githubDark: Theme = {\n  id: \"github-dark\",\n  label: \"GitHub\",\n  dark: true,\n  base: {\n    surface: \"hsl(213,30%,7%)\",\n    surfaceHighlight: \"hsl(213,16%,13%)\",\n    text: \"hsl(212,27%,89%)\",\n    textSubtle: \"hsl(212,9%,57%)\",\n    textSubtlest: \"hsl(217,8%,45%)\",\n    border: \"hsl(215,21%,11%)\",\n    primary: \"hsl(262,78%,74%)\",\n    secondary: \"hsl(217,8%,50%)\",\n    info: \"hsl(215,84%,64%)\",\n    success: \"hsl(129,48%,52%)\",\n    notice: \"hsl(39,71%,58%)\",\n    warning: \"hsl(22,83%,60%)\",\n    danger: \"hsl(3,83%,65%)\",\n  },\n  components: {\n    button: {\n      primary: \"hsl(262,79%,71%)\",\n      secondary: \"hsl(217,8%,45%)\",\n      info: \"hsl(215,84%,60%)\",\n      success: \"hsl(129,48%,47%)\",\n      notice: \"hsl(39,71%,53%)\",\n      warning: \"hsl(22,83%,56%)\",\n      danger: \"hsl(3,83%,61%)\",\n    },\n  },\n};\n\nexport const githubLight: Theme = {\n  id: \"github-light\",\n  label: \"GitHub\",\n  dark: false,\n  base: {\n    surface: \"hsl(0,0%,100%)\",\n    surfaceHighlight: \"hsl(210,29%,94%)\",\n    text: \"hsl(213,13%,14%)\",\n    textSubtle: \"hsl(212,9%,43%)\",\n    textSubtlest: \"hsl(203,8%,55%)\",\n    border: \"hsl(210,15%,92%)\",\n    borderSubtle: \"hsl(210,15%,92%)\",\n    primary: \"hsl(261,69%,59%)\",\n    secondary: \"hsl(212,8%,47%)\",\n    info: \"hsl(212,92%,48%)\",\n    success: \"hsl(137,66%,32%)\",\n    notice: \"hsl(40,100%,40%)\",\n    warning: \"hsl(24,100%,44%)\",\n    danger: \"hsl(356,71%,48%)\",\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/gruvbox.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const gruvbox: Theme = {\n  id: \"gruvbox\",\n  label: \"Gruvbox\",\n  dark: true,\n  base: {\n    surface: \"hsl(0,0%,16%)\",\n    surfaceHighlight: \"hsl(20,3%,19%)\",\n    text: \"hsl(53,74%,91%)\",\n    textSubtle: \"hsl(39,24%,66%)\",\n    textSubtlest: \"hsl(30,12%,51%)\",\n    primary: \"hsl(344,47%,68%)\",\n    secondary: \"hsl(157,16%,58%)\",\n    info: \"hsl(104,35%,62%)\",\n    success: \"hsl(61,66%,44%)\",\n    notice: \"hsl(42,95%,58%)\",\n    warning: \"hsl(27,99%,55%)\",\n    danger: \"hsl(6,96%,59%)\",\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/high-contrast.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const highContrast: Theme = {\n  id: \"high-contrast\",\n  label: \"High Contrast Light\",\n  dark: false,\n  base: {\n    surface: \"white\",\n    surfaceHighlight: \"hsl(218,24%,93%)\",\n    text: \"black\",\n    textSubtle: \"hsl(217,24%,40%)\",\n    textSubtlest: \"hsl(217,24%,40%)\",\n    border: \"hsl(217,22%,50%)\",\n    borderSubtle: \"hsl(217,22%,60%)\",\n    primary: \"hsl(267,67%,47%)\",\n    secondary: \"hsl(218,18%,53%)\",\n    info: \"hsl(206,100%,36%)\",\n    success: \"hsl(155,100%,26%)\",\n    notice: \"hsl(45,100%,31%)\",\n    warning: \"hsl(30,99%,34%)\",\n    danger: \"hsl(334,100%,35%)\",\n  },\n};\n\nexport const highContrastDark: Theme = {\n  id: \"high-contrast-dark\",\n  label: \"High Contrast Dark\",\n  dark: true,\n  base: {\n    surface: \"hsl(0,0%,0%)\",\n    surfaceHighlight: \"hsl(0,0%,20%)\",\n    text: \"hsl(0,0%,100%)\",\n    textSubtle: \"hsl(0,0%,90%)\",\n    textSubtlest: \"hsl(0,0%,80%)\",\n    selection: \"hsl(276,100%,30%)\",\n    surfaceActive: \"hsl(276,100%,30%)\",\n    border: \"hsl(0,0%,60%)\",\n    primary: \"hsl(266,100%,85%)\",\n    secondary: \"hsl(242,20%,72%)\",\n    info: \"hsl(208,100%,83%)\",\n    success: \"hsl(150,100%,63%)\",\n    notice: \"hsl(49,100%,77%)\",\n    warning: \"hsl(28,100%,73%)\",\n    danger: \"hsl(343,100%,79%)\",\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/horizon.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const horizon: Theme = {\n  id: \"horizon\",\n  label: \"Horizon\",\n  dark: true,\n  base: {\n    surface: \"hsl(220, 16%, 13%)\",\n    surfaceHighlight: \"hsl(220, 14%, 18%)\",\n    text: \"hsl(220, 15%, 85%)\",\n    textSubtle: \"hsl(220, 10%, 55%)\",\n    textSubtlest: \"hsl(220, 8%, 45%)\",\n    primary: \"hsl(5, 85%, 68%)\",\n    secondary: \"hsl(220, 10%, 55%)\",\n    info: \"hsl(217, 70%, 68%)\",\n    success: \"hsl(92, 50%, 60%)\",\n    notice: \"hsl(34, 92%, 70%)\",\n    warning: \"hsl(20, 90%, 65%)\",\n    danger: \"hsl(355, 80%, 65%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(220, 16%, 10%)\",\n    },\n    sidebar: {\n      surface: \"hsl(220, 14%, 15%)\",\n      border: \"hsl(220, 14%, 19%)\",\n    },\n    appHeader: {\n      surface: \"hsl(220, 16%, 11%)\",\n      border: \"hsl(220, 14%, 17%)\",\n    },\n    responsePane: {\n      surface: \"hsl(220, 14%, 15%)\",\n      border: \"hsl(220, 14%, 19%)\",\n    },\n    button: {\n      primary: \"hsl(5, 85%, 61%)\",\n      secondary: \"hsl(224,8%,53%)\",\n      info: \"hsl(217, 70%, 61%)\",\n      success: \"hsl(92, 50%, 53%)\",\n      notice: \"hsl(34, 92%, 63%)\",\n      warning: \"hsl(20, 90%, 58%)\",\n      danger: \"hsl(355, 80%, 58%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/hotdog-stand.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const hotdogStand: Theme = {\n  id: \"hotdog-stand\",\n  label: \"Hotdog Stand\",\n  dark: true,\n  base: {\n    surface: \"hsl(0,100%,50%)\",\n    surfaceHighlight: \"hsl(0,0%,0%)\",\n    text: \"hsl(0,0%,100%)\",\n    textSubtle: \"hsl(0,0%,100%)\",\n    textSubtlest: \"hsl(60,100%,50%)\",\n    border: \"hsl(0,0%,0%)\",\n    primary: \"hsl(60,100%,50%)\",\n    secondary: \"hsl(60,100%,50%)\",\n    info: \"hsl(60,100%,50%)\",\n    success: \"hsl(60,100%,50%)\",\n    notice: \"hsl(60,100%,50%)\",\n    warning: \"hsl(60,100%,50%)\",\n    danger: \"hsl(60,100%,50%)\",\n  },\n  components: {\n    appHeader: {\n      surface: \"hsl(0,0%,0%)\",\n      text: \"hsl(0,0%,100%)\",\n      textSubtle: \"hsl(60,100%,50%)\",\n      textSubtlest: \"hsl(0,100%,50%)\",\n    },\n    menu: {\n      surface: \"hsl(0,0%,0%)\",\n      border: \"hsl(0,100%,50%)\",\n      surfaceHighlight: \"hsl(0,100%,50%)\",\n      text: \"hsl(0,0%,100%)\",\n      textSubtle: \"hsl(60,100%,50%)\",\n      textSubtlest: \"hsl(60,100%,50%)\",\n    },\n    button: {\n      surface: \"hsl(0,0%,0%)\",\n      text: \"hsl(0,0%,100%)\",\n      primary: \"hsl(0,0%,0%)\",\n      secondary: \"hsl(0,0%,100%)\",\n      info: \"hsl(0,0%,0%)\",\n      success: \"hsl(60,100%,50%)\",\n      notice: \"hsl(60,100%,50%)\",\n      warning: \"hsl(0,0%,0%)\",\n      danger: \"hsl(0,100%,50%)\",\n    },\n    editor: {\n      primary: \"hsl(0,0%,100%)\",\n      secondary: \"hsl(0,0%,100%)\",\n      info: \"hsl(0,0%,100%)\",\n      success: \"hsl(0,0%,100%)\",\n      notice: \"hsl(60,100%,50%)\",\n      warning: \"hsl(0,0%,100%)\",\n      danger: \"hsl(0,0%,100%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/material-darker.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const materialDarker: Theme = {\n  id: \"material-darker\",\n  label: \"Material Darker\",\n  dark: true,\n  base: {\n    surface: \"hsl(0, 0%, 13%)\",\n    surfaceHighlight: \"hsl(0, 0%, 18%)\",\n    text: \"hsl(0, 0%, 93%)\",\n    textSubtle: \"hsl(0, 0%, 65%)\",\n    textSubtlest: \"hsl(0, 0%, 50%)\",\n    primary: \"hsl(262, 100%, 75%)\",\n    secondary: \"hsl(0, 0%, 60%)\",\n    info: \"hsl(224, 100%, 75%)\",\n    success: \"hsl(84, 60%, 73%)\",\n    notice: \"hsl(43, 100%, 70%)\",\n    warning: \"hsl(14, 85%, 70%)\",\n    danger: \"hsl(1, 77%, 59%)\",\n  },\n  components: {\n    sidebar: {\n      surface: \"hsl(0, 0%, 11%)\",\n      border: \"hsl(0, 0%, 16%)\",\n    },\n    appHeader: {\n      surface: \"hsl(0, 0%, 9%)\",\n      border: \"hsl(0, 0%, 14%)\",\n    },\n    button: {\n      primary: \"hsl(262, 100%, 68%)\",\n      info: \"hsl(224, 100%, 68%)\",\n      success: \"hsl(84, 60%, 66%)\",\n      notice: \"hsl(43, 100%, 63%)\",\n      warning: \"hsl(14, 85%, 63%)\",\n      danger: \"hsl(1, 77%, 52%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/material-ocean.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const materialOcean: Theme = {\n  id: \"material-ocean\",\n  label: \"Material Ocean\",\n  dark: true,\n  base: {\n    surface: \"hsl(230, 25%, 14%)\",\n    surfaceHighlight: \"hsl(230, 20%, 18%)\",\n    text: \"hsl(220, 53%, 85%)\",\n    textSubtle: \"hsl(228, 12%, 54%)\",\n    textSubtlest: \"hsl(228, 12%, 42%)\",\n    primary: \"hsl(262, 100%, 75%)\",\n    secondary: \"hsl(228, 12%, 60%)\",\n    info: \"hsl(224, 100%, 75%)\",\n    success: \"hsl(84, 60%, 73%)\",\n    notice: \"hsl(43, 100%, 70%)\",\n    warning: \"hsl(14, 85%, 70%)\",\n    danger: \"hsl(1, 77%, 59%)\",\n  },\n  components: {\n    sidebar: {\n      surface: \"hsl(230, 25%, 12%)\",\n      border: \"hsl(230, 20%, 18%)\",\n    },\n    appHeader: {\n      surface: \"hsl(230, 25%, 10%)\",\n      border: \"hsl(230, 20%, 16%)\",\n    },\n    responsePane: {\n      surface: \"hsl(230, 25%, 12%)\",\n      border: \"hsl(230, 20%, 18%)\",\n    },\n    button: {\n      primary: \"hsl(262, 100%, 68%)\",\n      info: \"hsl(224, 100%, 68%)\",\n      success: \"hsl(84, 60%, 66%)\",\n      notice: \"hsl(43, 100%, 63%)\",\n      warning: \"hsl(14, 85%, 63%)\",\n      danger: \"hsl(1, 77%, 52%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/material-palenight.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const materialPalenight: Theme = {\n  id: \"material-palenight\",\n  label: \"Material Palenight\",\n  dark: true,\n  base: {\n    surface: \"#292D3E\",\n    surfaceHighlight: \"#313850\",\n    text: \"#BFC7D5\",\n    textSubtle: \"#697098\",\n    textSubtlest: \"#4E5579\",\n    primary: \"#c792ea\",\n    secondary: \"#697098\",\n    info: \"#82AAFF\",\n    success: \"#C3E88D\",\n    notice: \"#FFCB6B\",\n    warning: \"#F78C6C\",\n    danger: \"#ff5572\",\n  },\n  components: {\n    dialog: {\n      surface: \"#232635\",\n    },\n    sidebar: {\n      surface: \"#292D3E\",\n    },\n    appHeader: {\n      surface: \"#282C3D\",\n    },\n    responsePane: {\n      surface: \"#313850\",\n      border: \"#3a3f58\",\n    },\n    button: {\n      primary: \"#c792ea\",\n      secondary: \"#697098\",\n      info: \"#82AAFF\",\n      success: \"#C3E88D\",\n      notice: \"#FFCB6B\",\n      warning: \"#F78C6C\",\n      danger: \"#ff5572\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/monokai-pro.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const monokaiPro: Theme = {\n  id: \"monokai-pro\",\n  label: \"Monokai Pro\",\n  dark: true,\n  base: {\n    surface: \"hsl(285,5%,17%)\",\n    text: \"hsl(60,25%,98%)\",\n    textSubtle: \"hsl(0,1%,75%)\",\n    textSubtlest: \"hsl(300,0%,57%)\",\n    primary: \"hsl(250,77%,78%)\",\n    secondary: \"hsl(0,1%,75%)\",\n    info: \"hsl(186,71%,69%)\",\n    success: \"hsl(90,59%,66%)\",\n    notice: \"hsl(45,100%,70%)\",\n    warning: \"hsl(20,96%,70%)\",\n    danger: \"hsl(345,100%,69%)\",\n  },\n  components: {\n    appHeader: {\n      surface: \"hsl(300,5%,13%)\",\n      text: \"hsl(0,1%,75%)\",\n      textSubtle: \"hsl(300,0%,57%)\",\n      textSubtlest: \"hsl(300,1%,44%)\",\n    },\n    button: {\n      primary: \"hsl(250,77%,70%)\",\n      secondary: \"hsl(0,1%,68%)\",\n      info: \"hsl(186,71%,62%)\",\n      success: \"hsl(90,59%,59%)\",\n      notice: \"hsl(45,100%,63%)\",\n      warning: \"hsl(20,96%,63%)\",\n      danger: \"hsl(345,100%,62%)\",\n    },\n  },\n};\n\nexport const monokaiProClassic: Theme = {\n  id: \"monokai-pro-classic\",\n  label: \"Monokai Pro Classic\",\n  dark: true,\n  base: {\n    surface: \"hsl(70,8%,15%)\",\n    text: \"hsl(69,100%,97%)\",\n    textSubtle: \"hsl(65,9%,73%)\",\n    textSubtlest: \"hsl(66,4%,55%)\",\n    primary: \"hsl(261,100%,75%)\",\n    secondary: \"hsl(202,8%,72%)\",\n    info: \"hsl(190,81%,67%)\",\n    success: \"hsl(80,76%,53%)\",\n    notice: \"hsl(54,70%,68%)\",\n    warning: \"hsl(32,98%,56%)\",\n    danger: \"hsl(338,95%,56%)\",\n  },\n  components: {\n    appHeader: {\n      surface: \"hsl(72,9%,11%)\",\n      text: \"hsl(202,8%,72%)\",\n      textSubtle: \"hsl(213,4%,48%)\",\n      textSubtlest: \"hsl(223,6%,44%)\",\n    },\n    button: {\n      primary: \"hsl(261,100%,68%)\",\n      secondary: \"hsl(202,8%,65%)\",\n      info: \"hsl(190,81%,60%)\",\n      success: \"hsl(80,76%,48%)\",\n      notice: \"hsl(54,71%,61%)\",\n      warning: \"hsl(32,98%,50%)\",\n      danger: \"hsl(338,95%,50%)\",\n    },\n  },\n};\n\nexport const monokaiProMachine: Theme = {\n  id: \"monokai-pro-machine\",\n  label: \"Monokai Pro Machine\",\n  dark: true,\n  base: {\n    surface: \"hsl(200,16%,18%)\",\n    text: \"hsl(173,24%,93%)\",\n    textSubtle: \"hsl(185,6%,57%)\",\n    textSubtlest: \"hsl(189,6%,45%)\",\n    primary: \"hsl(258,86%,80%)\",\n    secondary: \"hsl(175,9%,75%)\",\n    info: \"hsl(194,81%,72%)\",\n    success: \"hsl(98,67%,69%)\",\n    notice: \"hsl(52,100%,72%)\",\n    warning: \"hsl(28,100%,72%)\",\n    danger: \"hsl(353,100%,71%)\",\n  },\n  components: {\n    appHeader: {\n      surface: \"hsl(196,16%,14%)\",\n      text: \"hsl(202,8%,72%)\",\n      textSubtle: \"hsl(213,4%,48%)\",\n      textSubtlest: \"hsl(223,6%,44%)\",\n    },\n    button: {\n      primary: \"hsl(258,86%,72%)\",\n      secondary: \"hsl(175,9%,68%)\",\n      info: \"hsl(194,80%,65%)\",\n      success: \"hsl(98,67%,62%)\",\n      notice: \"hsl(52,100%,65%)\",\n      warning: \"hsl(28,100%,65%)\",\n      danger: \"hsl(353,100%,64%)\",\n    },\n  },\n};\n\nexport const monokaiProOctagon: Theme = {\n  id: \"monokai-pro-octagon\",\n  label: \"Monokai Pro Octagon\",\n  dark: true,\n  base: {\n    surface: \"hsl(233,18%,19%)\",\n    text: \"hsl(173,24%,93%)\",\n    textSubtle: \"hsl(202,8%,72%)\",\n    textSubtlest: \"hsl(213,4%,48%)\",\n    primary: \"hsl(292,30%,70%)\",\n    secondary: \"hsl(202,8%,72%)\",\n    info: \"hsl(155,37%,72%)\",\n    success: \"hsl(75,60%,61%)\",\n    notice: \"hsl(44,100%,71%)\",\n    warning: \"hsl(23,100%,68%)\",\n    danger: \"hsl(352,100%,70%)\",\n  },\n  components: {\n    appHeader: {\n      surface: \"hsl(235,18%,14%)\",\n      text: \"hsl(202,8%,72%)\",\n      textSubtle: \"hsl(213,4%,48%)\",\n      textSubtlest: \"hsl(223,6%,44%)\",\n    },\n    button: {\n      primary: \"hsl(292,26%,63%)\",\n      secondary: \"hsl(201,7%,65%)\",\n      info: \"hsl(155,33%,65%)\",\n      success: \"hsl(75,54%,55%)\",\n      notice: \"hsl(44,90%,64%)\",\n      warning: \"hsl(23,90%,61%)\",\n      danger: \"hsl(352,90%,63%)\",\n    },\n  },\n};\n\nexport const monokaiProRistretto: Theme = {\n  id: \"monokai-pro-ristretto\",\n  label: \"Monokai Pro Ristretto\",\n  dark: true,\n  base: {\n    surface: \"hsl(0,9%,16%)\",\n    text: \"hsl(351,100%,97%)\",\n    textSubtle: \"hsl(355,9%,74%)\",\n    textSubtlest: \"hsl(354,4%,56%)\",\n    primary: \"hsl(239,63%,79%)\",\n    secondary: \"hsl(355,9%,74%)\",\n    info: \"hsl(170,53%,69%)\",\n    success: \"hsl(88,57%,66%)\",\n    notice: \"hsl(41,92%,70%)\",\n    warning: \"hsl(13,85%,70%)\",\n    danger: \"hsl(349,97%,70%)\",\n  },\n  components: {\n    appHeader: {\n      surface: \"hsl(0,8%,12%)\",\n      text: \"hsl(355,9%,74%)\",\n      textSubtle: \"hsl(354,4%,56%)\",\n      textSubtlest: \"hsl(353,4%,43%)\",\n    },\n    button: {\n      primary: \"hsl(239,63%,71%)\",\n      secondary: \"hsl(355,9%,67%)\",\n      info: \"hsl(170,53%,62%)\",\n      success: \"hsl(88,57%,59%)\",\n      notice: \"hsl(41,92%,63%)\",\n      warning: \"hsl(13,86%,63%)\",\n      danger: \"hsl(349,97%,63%)\",\n    },\n  },\n};\n\nexport const monokaiProSpectrum: Theme = {\n  id: \"monokai-pro-spectrum\",\n  label: \"Monokai Pro Spectrum\",\n  dark: true,\n  base: {\n    surface: \"hsl(0,0%,13%)\",\n    text: \"hsl(266,100%,97%)\",\n    textSubtle: \"hsl(264,7%,73%)\",\n    textSubtlest: \"hsl(266,3%,55%)\",\n    primary: \"hsl(247,61%,72%)\",\n    secondary: \"hsl(264,7%,73%)\",\n    info: \"hsl(188,74%,63%)\",\n    success: \"hsl(133,54%,66%)\",\n    notice: \"hsl(51,96%,69%)\",\n    warning: \"hsl(23,98%,66%)\",\n    danger: \"hsl(343,96%,68%)\",\n  },\n  components: {\n    appHeader: {\n      surface: \"hsl(0,0%,10%)\",\n      text: \"hsl(264,7%,73%)\",\n      textSubtle: \"hsl(266,3%,55%)\",\n      textSubtlest: \"hsl(264,2%,41%)\",\n    },\n    button: {\n      primary: \"hsl(247,61%,65%)\",\n      secondary: \"hsl(264,7%,66%)\",\n      info: \"hsl(188,74%,57%)\",\n      success: \"hsl(133,54%,59%)\",\n      notice: \"hsl(51,96%,62%)\",\n      warning: \"hsl(23,98%,59%)\",\n      danger: \"hsl(343,96%,61%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/moonlight.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const moonlight: Theme = {\n  id: \"moonlight\",\n  label: \"Moonlight\",\n  dark: true,\n  base: {\n    surface: \"hsl(234,23%,17%)\",\n    text: \"hsl(225,71%,90%)\",\n    textSubtle: \"hsl(230,28%,62%)\",\n    textSubtlest: \"hsl(232,26%,43%)\",\n    primary: \"hsl(262,100%,82%)\",\n    secondary: \"hsl(232,18%,65%)\",\n    info: \"hsl(217,100%,74%)\",\n    success: \"hsl(174,66%,54%)\",\n    notice: \"hsl(35,100%,73%)\",\n    warning: \"hsl(17,100%,71%)\",\n    danger: \"hsl(356,100%,73%)\",\n  },\n  components: {\n    appHeader: {\n      surface: \"hsl(233,23%,15%)\",\n    },\n    sidebar: {\n      surface: \"hsl(233,23%,15%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/night-owl.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const nightOwl: Theme = {\n  id: \"night-owl\",\n  label: \"Night Owl\",\n  dark: true,\n  base: {\n    surface: \"hsl(207, 95%, 8%)\",\n    surfaceHighlight: \"hsl(207, 50%, 14%)\",\n    text: \"hsl(213, 50%, 90%)\",\n    textSubtle: \"hsl(213, 30%, 70%)\",\n    textSubtlest: \"hsl(213, 20%, 50%)\",\n    border: \"hsl(207, 50%, 14%)\",\n    primary: \"hsl(261, 51%, 51%)\",\n    secondary: \"hsl(213, 30%, 60%)\",\n    info: \"hsl(220, 100%, 75%)\",\n    success: \"hsl(145, 100%, 43%)\",\n    notice: \"hsl(62, 61%, 71%)\",\n    warning: \"hsl(4, 90%, 58%)\",\n    danger: \"hsl(4, 90%, 58%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(207, 95%, 6%)\",\n    },\n    sidebar: {\n      surface: \"hsl(207, 95%, 8%)\",\n      border: \"hsl(207, 50%, 14%)\",\n    },\n    appHeader: {\n      surface: \"hsl(207, 95%, 5%)\",\n      border: \"hsl(207, 50%, 12%)\",\n    },\n    responsePane: {\n      surface: \"hsl(207, 70%, 10%)\",\n      border: \"hsl(207, 50%, 14%)\",\n    },\n    button: {\n      primary: \"hsl(261, 51%, 45%)\",\n      secondary: \"hsl(213, 30%, 60%)\",\n      info: \"hsl(220, 100%, 68%)\",\n      success: \"hsl(145, 100%, 38%)\",\n      notice: \"hsl(62, 61%, 64%)\",\n      warning: \"hsl(4, 90%, 52%)\",\n      danger: \"hsl(4, 90%, 52%)\",\n    },\n  },\n};\n\nexport const lightOwl: Theme = {\n  id: \"light-owl\",\n  label: \"Light Owl\",\n  dark: false,\n  base: {\n    surface: \"hsl(0, 0%, 98%)\",\n    surfaceHighlight: \"hsl(210, 18%, 94%)\",\n    text: \"hsl(224, 26%, 27%)\",\n    textSubtle: \"hsl(224, 15%, 45%)\",\n    textSubtlest: \"hsl(224, 10%, 55%)\",\n    primary: \"hsl(283, 100%, 41%)\",\n    secondary: \"hsl(224, 15%, 50%)\",\n    info: \"hsl(219, 75%, 40%)\",\n    success: \"hsl(145, 70%, 35%)\",\n    notice: \"hsl(36, 95%, 40%)\",\n    warning: \"hsl(0, 55%, 55%)\",\n    danger: \"hsl(0, 55%, 50%)\",\n  },\n  components: {\n    sidebar: {\n      surface: \"hsl(210, 20%, 96%)\",\n      border: \"hsl(210, 15%, 90%)\",\n    },\n    appHeader: {\n      surface: \"hsl(210, 20%, 94%)\",\n      border: \"hsl(210, 15%, 88%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/noctis.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const noctisAzureus: Theme = {\n  id: \"noctis-azureus\",\n  label: \"Noctis Azureus\",\n  dark: true,\n  base: {\n    surface: \"hsl(210, 35%, 14%)\",\n    surfaceHighlight: \"hsl(210, 30%, 19%)\",\n    text: \"hsl(180, 45%, 85%)\",\n    textSubtle: \"hsl(180, 25%, 60%)\",\n    textSubtlest: \"hsl(180, 18%, 45%)\",\n    primary: \"hsl(175, 60%, 55%)\",\n    secondary: \"hsl(200, 70%, 65%)\",\n    info: \"hsl(200, 70%, 65%)\",\n    success: \"hsl(85, 55%, 60%)\",\n    notice: \"hsl(45, 90%, 60%)\",\n    warning: \"hsl(25, 85%, 58%)\",\n    danger: \"hsl(355, 75%, 62%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(210, 35%, 11%)\",\n    },\n    sidebar: {\n      surface: \"hsl(210, 33%, 12%)\",\n      border: \"hsl(210, 30%, 17%)\",\n    },\n    appHeader: {\n      surface: \"hsl(210, 35%, 10%)\",\n      border: \"hsl(210, 30%, 15%)\",\n    },\n    responsePane: {\n      surface: \"hsl(210, 33%, 12%)\",\n      border: \"hsl(210, 30%, 17%)\",\n    },\n    button: {\n      primary: \"hsl(175, 60%, 48%)\",\n      secondary: \"hsl(200, 70%, 58%)\",\n      info: \"hsl(200, 70%, 58%)\",\n      success: \"hsl(85, 55%, 53%)\",\n      notice: \"hsl(45, 90%, 53%)\",\n      warning: \"hsl(25, 85%, 51%)\",\n      danger: \"hsl(355, 75%, 55%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/nord.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const nord: Theme = {\n  id: \"nord\",\n  label: \"Nord\",\n  dark: true,\n  base: {\n    surface: \"hsl(220,16%,22%)\",\n    surfaceHighlight: \"hsl(220,14%,28%)\",\n    text: \"hsl(220,28%,93%)\",\n    textSubtle: \"hsl(220,26%,90%)\",\n    textSubtlest: \"hsl(220,24%,86%)\",\n    primary: \"hsl(193,38%,68%)\",\n    secondary: \"hsl(210,34%,63%)\",\n    info: \"hsl(174,25%,69%)\",\n    success: \"hsl(89,26%,66%)\",\n    notice: \"hsl(40,66%,73%)\",\n    warning: \"hsl(17,48%,64%)\",\n    danger: \"hsl(353,43%,56%)\",\n  },\n  components: {\n    sidebar: {\n      surface: \"hsl(220,16%,22%)\",\n    },\n    appHeader: {\n      surface: \"hsl(220,14%,28%)\",\n    },\n  },\n};\n\nexport const nordLight: Theme = {\n  id: \"nord-light\",\n  label: \"Nord Light\",\n  dark: false,\n  base: {\n    surface: \"#eceff4\",\n    surfaceHighlight: \"#e5e9f0\",\n    text: \"#24292e\",\n    textSubtle: \"#444d56\",\n    textSubtlest: \"#586069\",\n    primary: \"#2188ff\",\n    secondary: \"#586069\",\n    info: \"#005cc5\",\n    success: \"#28a745\",\n    notice: \"#e36209\",\n    warning: \"#e36209\",\n    danger: \"#cb2431\",\n  },\n  components: {\n    sidebar: {\n      surface: \"#e5e9f0\",\n    },\n    appHeader: {\n      surface: \"#e5e9f0\",\n    },\n  },\n};\n\nexport const nordLightBrighter: Theme = {\n  id: \"nord-light-brighter\",\n  label: \"Nord Light Brighter\",\n  dark: false,\n  base: {\n    surface: \"#ffffff\",\n    surfaceHighlight: \"#f6f8fa\",\n    text: \"#24292e\",\n    textSubtle: \"#444d56\",\n    textSubtlest: \"#586069\",\n    primary: \"#2188ff\",\n    secondary: \"#586069\",\n    info: \"#005cc5\",\n    success: \"#28a745\",\n    notice: \"#e36209\",\n    warning: \"#e36209\",\n    danger: \"#cb2431\",\n  },\n  components: {\n    sidebar: {\n      surface: \"#f6f8fa\",\n    },\n    appHeader: {\n      surface: \"#f6f8fa\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/one-dark-pro.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const oneDarkPro: Theme = {\n  id: \"one-dark-pro\",\n  label: \"One Dark Pro\",\n  dark: true,\n  base: {\n    surface: \"hsl(220, 13%, 18%)\",\n    surfaceHighlight: \"hsl(220, 13%, 22%)\",\n    text: \"hsl(219, 14%, 71%)\",\n    textSubtle: \"hsl(219, 10%, 53%)\",\n    textSubtlest: \"hsl(220, 9%, 45%)\",\n    primary: \"hsl(286, 60%, 67%)\",\n    secondary: \"hsl(219, 14%, 60%)\",\n    info: \"hsl(207, 82%, 66%)\",\n    success: \"hsl(95, 38%, 62%)\",\n    notice: \"hsl(39, 67%, 69%)\",\n    warning: \"hsl(29, 54%, 61%)\",\n    danger: \"hsl(355, 65%, 65%)\",\n  },\n  components: {\n    sidebar: {\n      surface: \"hsl(220, 13%, 16%)\",\n      border: \"hsl(220, 13%, 20%)\",\n    },\n    appHeader: {\n      surface: \"hsl(220, 13%, 14%)\",\n      border: \"hsl(220, 13%, 20%)\",\n    },\n    responsePane: {\n      surface: \"hsl(220, 13%, 16%)\",\n      border: \"hsl(220, 13%, 20%)\",\n    },\n    button: {\n      primary: \"hsl(286, 60%, 60%)\",\n      secondary: \"hsl(219, 14%, 53%)\",\n      info: \"hsl(207, 82%, 59%)\",\n      success: \"hsl(95, 38%, 55%)\",\n      notice: \"hsl(39, 67%, 62%)\",\n      warning: \"hsl(29, 54%, 54%)\",\n      danger: \"hsl(355, 65%, 58%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/panda.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const pandaSyntax: Theme = {\n  id: \"panda\",\n  label: \"Panda Syntax\",\n  dark: true,\n  base: {\n    surface: \"hsl(225, 15%, 15%)\",\n    surfaceHighlight: \"hsl(225, 12%, 20%)\",\n    text: \"hsl(0, 0%, 90%)\",\n    textSubtle: \"hsl(0, 0%, 65%)\",\n    textSubtlest: \"hsl(0, 0%, 50%)\",\n    primary: \"hsl(353, 95%, 70%)\",\n    secondary: \"hsl(0, 0%, 65%)\",\n    info: \"hsl(200, 85%, 65%)\",\n    success: \"hsl(175, 90%, 65%)\",\n    notice: \"hsl(40, 100%, 65%)\",\n    warning: \"hsl(40, 100%, 65%)\",\n    danger: \"hsl(0, 90%, 65%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(225, 15%, 12%)\",\n    },\n    sidebar: {\n      surface: \"hsl(225, 14%, 13%)\",\n      border: \"hsl(225, 12%, 18%)\",\n    },\n    appHeader: {\n      surface: \"hsl(225, 15%, 11%)\",\n      border: \"hsl(225, 12%, 16%)\",\n    },\n    responsePane: {\n      surface: \"hsl(225, 14%, 13%)\",\n      border: \"hsl(225, 12%, 18%)\",\n    },\n    button: {\n      primary: \"hsl(353, 95%, 63%)\",\n      secondary: \"hsl(0, 0%, 58%)\",\n      info: \"hsl(200, 85%, 58%)\",\n      success: \"hsl(175, 90%, 58%)\",\n      notice: \"hsl(40, 100%, 58%)\",\n      warning: \"hsl(40, 100%, 58%)\",\n      danger: \"hsl(0, 90%, 58%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/relaxing.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const relaxing: Theme = {\n  id: \"relaxing\",\n  label: \"Relaxing\",\n  dark: true,\n  base: {\n    surface: \"hsl(267,33%,17%)\",\n    text: \"hsl(275,49%,92%)\",\n    primary: \"hsl(267,84%,81%)\",\n    secondary: \"hsl(227,35%,80%)\",\n    info: \"hsl(217,92%,76%)\",\n    success: \"hsl(115,54%,76%)\",\n    notice: \"hsl(41,86%,83%)\",\n    warning: \"hsl(23,92%,75%)\",\n    danger: \"hsl(343,81%,75%)\",\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/rose-pine.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const rosePine: Theme = {\n  id: \"rose-pine\",\n  label: \"Rosé Pine\",\n  dark: true,\n  base: {\n    surface: \"hsl(249,22%,12%)\",\n    text: \"hsl(245,50%,91%)\",\n    textSubtle: \"hsl(248,15%,61%)\",\n    textSubtlest: \"hsl(249,12%,47%)\",\n    primary: \"hsl(267,57%,78%)\",\n    secondary: \"hsl(249,12%,47%)\",\n    info: \"hsl(199,49%,60%)\",\n    success: \"hsl(180,43%,73%)\",\n    notice: \"hsl(35,88%,72%)\",\n    warning: \"hsl(1,74%,79%)\",\n    danger: \"hsl(343,76%,68%)\",\n  },\n  components: {\n    responsePane: {\n      surface: \"hsl(247,23%,15%)\",\n    },\n    sidebar: {\n      surface: \"hsl(247,23%,15%)\",\n    },\n    menu: {\n      surface: \"hsl(248,21%,26%)\",\n      textSubtle: \"hsl(248,15%,66%)\",\n      textSubtlest: \"hsl(249,12%,52%)\",\n      border: \"hsl(248,21%,35%)\",\n      borderSubtle: \"hsl(248,21%,33%)\",\n    },\n  },\n};\n\nexport const rosePineMoon: Theme = {\n  id: \"rose-pine-moon\",\n  label: \"Rosé Pine Moon\",\n  dark: true,\n  base: {\n    surface: \"hsl(246,24%,17%)\",\n    text: \"hsl(245,50%,91%)\",\n    textSubtle: \"hsl(248,15%,61%)\",\n    textSubtlest: \"hsl(249,12%,47%)\",\n    primary: \"hsl(267,57%,78%)\",\n    secondary: \"hsl(248,15%,61%)\",\n    info: \"hsl(197,48%,60%)\",\n    success: \"hsl(197,48%,60%)\",\n    notice: \"hsl(35,88%,72%)\",\n    warning: \"hsl(2,66%,75%)\",\n    danger: \"hsl(343,76%,68%)\",\n  },\n  components: {\n    responsePane: {\n      surface: \"hsl(247,24%,20%)\",\n    },\n    sidebar: {\n      surface: \"hsl(247,24%,20%)\",\n    },\n    menu: {\n      surface: \"hsl(248,21%,26%)\",\n      textSubtle: \"hsl(248,15%,61%)\",\n      textSubtlest: \"hsl(249,12%,55%)\",\n      border: \"hsl(248,21%,35%)\",\n      borderSubtle: \"hsl(248,21%,31%)\",\n    },\n  },\n};\n\nexport const rosePineDawn: Theme = {\n  id: \"rose-pine-dawn\",\n  label: \"Rosé Pine Dawn\",\n  dark: false,\n  base: {\n    surface: \"hsl(32,57%,95%)\",\n    border: \"hsl(10,9%,86%)\",\n    surfaceHighlight: \"hsl(25,35%,93%)\",\n    text: \"hsl(248,19%,40%)\",\n    textSubtle: \"hsl(248,12%,52%)\",\n    textSubtlest: \"hsl(257,9%,61%)\",\n    primary: \"hsl(271,27%,56%)\",\n    secondary: \"hsl(249,12%,47%)\",\n    info: \"hsl(197,52%,36%)\",\n    success: \"hsl(188,31%,45%)\",\n    notice: \"hsl(34,64%,49%)\",\n    warning: \"hsl(2,47%,64%)\",\n    danger: \"hsl(343,35%,55%)\",\n  },\n  components: {\n    responsePane: {\n      border: \"hsl(20,12%,90%)\",\n    },\n    sidebar: {\n      border: \"hsl(20,12%,90%)\",\n    },\n    appHeader: {\n      border: \"hsl(20,12%,90%)\",\n    },\n    input: {\n      border: \"hsl(10,9%,86%)\",\n    },\n    dialog: {\n      border: \"hsl(20,12%,90%)\",\n    },\n    menu: {\n      surface: \"hsl(28,40%,92%)\",\n      border: \"hsl(10,9%,86%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/shades-of-purple.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const shadesOfPurple: Theme = {\n  id: \"shades-of-purple\",\n  label: \"Shades of Purple\",\n  dark: true,\n  base: {\n    surface: \"#2D2B55\",\n    surfaceHighlight: \"#1F1F41\",\n    text: \"#FFFFFF\",\n    textSubtle: \"#A599E9\",\n    textSubtlest: \"#7E72C4\",\n    primary: \"#FAD000\",\n    secondary: \"#A599E9\",\n    info: \"#80FFBB\",\n    success: \"#3AD900\",\n    notice: \"#FAD000\",\n    warning: \"#FF9D00\",\n    danger: \"#EC3A37F5\",\n  },\n  components: {\n    dialog: {\n      surface: \"#1E1E3F\",\n    },\n    sidebar: {\n      surface: \"#222244\",\n      border: \"#1E1E3F\",\n    },\n    input: {\n      border: \"#7E72C4\",\n    },\n    appHeader: {\n      surface: \"#1E1E3F\",\n      border: \"#1E1E3F\",\n    },\n    responsePane: {\n      surface: \"hsl(240,33%,20%)\",\n      border: \"hsl(240,33%,20%)\",\n    },\n    button: {\n      primary: \"#FAD000\",\n      secondary: \"#A599E9\",\n      info: \"#80FFBB\",\n      success: \"#3AD900\",\n      notice: \"#FAD000\",\n      warning: \"#FF9D00\",\n      danger: \"#EC3A37F5\",\n    },\n  },\n};\n\nexport const shadesOfPurpleSuperDark: Theme = {\n  id: \"shades-of-purple-super-dark\",\n  label: \"Shades of Purple (Super Dark)\",\n  dark: true,\n  base: {\n    surface: \"#191830\",\n    surfaceHighlight: \"#1F1E3A\",\n    text: \"#FFFFFF\",\n    textSubtle: \"#A599E9\",\n    textSubtlest: \"#7E72C4\",\n    primary: \"#FAD000\",\n    secondary: \"#A599E9\",\n    info: \"#80FFBB\",\n    success: \"#3AD900\",\n    notice: \"#FAD000\",\n    warning: \"#FF9D00\",\n    danger: \"#EC3A37F5\",\n  },\n  components: {\n    dialog: {\n      surface: \"#15152b\",\n    },\n    input: {\n      border: \"#2D2B55\",\n    },\n    sidebar: {\n      surface: \"#131327\",\n      border: \"#131327\",\n    },\n    appHeader: {\n      surface: \"#15152a\",\n      border: \"#15152a\",\n    },\n    responsePane: {\n      surface: \"#131327\",\n      border: \"#131327\",\n    },\n    button: {\n      primary: \"#FAD000\",\n      secondary: \"#A599E9\",\n      info: \"#80FFBB\",\n      success: \"#3AD900\",\n      notice: \"#FAD000\",\n      warning: \"#FF9D00\",\n      danger: \"#EC3A37F5\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/slack.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const slackAubergine: Theme = {\n  id: \"slack-aubergine\",\n  label: \"Slack Aubergine\",\n  dark: true,\n  base: {\n    surface: \"hsl(270, 25%, 18%)\",\n    surfaceHighlight: \"hsl(270, 22%, 24%)\",\n    text: \"hsl(0, 0%, 100%)\",\n    textSubtle: \"hsl(270, 15%, 75%)\",\n    textSubtlest: \"hsl(270, 12%, 58%)\",\n    primary: \"hsl(165, 100%, 40%)\",\n    secondary: \"hsl(270, 12%, 65%)\",\n    info: \"hsl(195, 95%, 55%)\",\n    success: \"hsl(145, 80%, 50%)\",\n    notice: \"hsl(43, 100%, 55%)\",\n    warning: \"hsl(43, 100%, 50%)\",\n    danger: \"hsl(0, 80%, 55%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(270, 25%, 14%)\",\n    },\n    sidebar: {\n      surface: \"hsl(270, 23%, 15%)\",\n      border: \"hsl(270, 22%, 22%)\",\n    },\n    appHeader: {\n      surface: \"hsl(270, 25%, 13%)\",\n      border: \"hsl(270, 22%, 20%)\",\n    },\n    responsePane: {\n      surface: \"hsl(270, 23%, 15%)\",\n      border: \"hsl(270, 22%, 22%)\",\n    },\n    button: {\n      primary: \"hsl(165, 100%, 35%)\",\n      secondary: \"hsl(270, 12%, 58%)\",\n      info: \"hsl(195, 95%, 48%)\",\n      success: \"hsl(145, 80%, 45%)\",\n      notice: \"hsl(43, 100%, 48%)\",\n      warning: \"hsl(43, 100%, 45%)\",\n      danger: \"hsl(0, 80%, 48%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/solarized.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const solarizedDark: Theme = {\n  id: \"solarized-dark\",\n  label: \"Solarized Dark\",\n  dark: true,\n  base: {\n    surface: \"#002b36\",\n    surfaceHighlight: \"#073642\",\n    text: \"#839496\",\n    textSubtle: \"#657b83\",\n    textSubtlest: \"#586e75\",\n    primary: \"#268bd2\",\n    secondary: \"#657b83\",\n    info: \"#268bd2\",\n    success: \"#859900\",\n    notice: \"#b58900\",\n    warning: \"#cb4b16\",\n    danger: \"#dc322f\",\n  },\n  components: {\n    dialog: {\n      surface: \"#002b36\",\n    },\n    sidebar: {\n      surface: \"#073642\",\n      border: \"hsl(192,81%,17%)\",\n    },\n    appHeader: {\n      surface: \"#002b36\",\n      border: \"hsl(192,81%,16%)\",\n    },\n    responsePane: {\n      surface: \"#073642\",\n      border: \"hsl(192,81%,17%)\",\n    },\n    button: {\n      primary: \"#268bd2\",\n      secondary: \"#657b83\",\n      info: \"#268bd2\",\n      success: \"#859900\",\n      notice: \"#b58900\",\n      warning: \"#cb4b16\",\n      danger: \"#dc322f\",\n    },\n  },\n};\n\nexport const solarizedLight: Theme = {\n  id: \"solarized-light\",\n  label: \"Solarized Light\",\n  dark: false,\n  base: {\n    surface: \"#fdf6e3\",\n    surfaceHighlight: \"#eee8d5\",\n    text: \"#657b83\",\n    textSubtle: \"#839496\",\n    textSubtlest: \"#93a1a1\",\n    primary: \"#268bd2\",\n    secondary: \"#839496\",\n    info: \"#268bd2\",\n    success: \"#859900\",\n    notice: \"#b58900\",\n    warning: \"#cb4b16\",\n    danger: \"#dc322f\",\n  },\n  components: {\n    sidebar: {\n      surface: \"#eee8d5\",\n      border: \"#d3cbb7\",\n    },\n    appHeader: {\n      surface: \"#eee8d5\",\n      border: \"#d3cbb7\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/synthwave-84.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const synthwave84: Theme = {\n  id: \"synthwave-84\",\n  label: \"SynthWave '84\",\n  dark: true,\n  base: {\n    surface: \"hsl(253, 45%, 15%)\",\n    surfaceHighlight: \"hsl(253, 40%, 20%)\",\n    text: \"hsl(300, 50%, 90%)\",\n    textSubtle: \"hsl(280, 25%, 65%)\",\n    textSubtlest: \"hsl(280, 20%, 50%)\",\n    primary: \"hsl(320, 100%, 75%)\",\n    secondary: \"hsl(280, 20%, 60%)\",\n    info: \"hsl(177, 100%, 55%)\",\n    success: \"hsl(83, 100%, 60%)\",\n    notice: \"hsl(57, 100%, 60%)\",\n    warning: \"hsl(30, 100%, 60%)\",\n    danger: \"hsl(340, 100%, 65%)\",\n  },\n  components: {\n    sidebar: {\n      surface: \"hsl(253, 42%, 18%)\",\n      border: \"hsl(253, 40%, 22%)\",\n    },\n    appHeader: {\n      surface: \"hsl(253, 45%, 11%)\",\n      border: \"hsl(253, 40%, 18%)\",\n    },\n    responsePane: {\n      surface: \"hsl(253, 42%, 18%)\",\n      border: \"hsl(253, 40%, 22%)\",\n    },\n    button: {\n      primary: \"hsl(320, 100%, 68%)\",\n      secondary: \"hsl(280, 20%, 53%)\",\n      info: \"hsl(177, 100%, 48%)\",\n      success: \"hsl(83, 100%, 53%)\",\n      notice: \"hsl(57, 100%, 53%)\",\n      warning: \"hsl(30, 100%, 53%)\",\n      danger: \"hsl(340, 100%, 58%)\",\n    },\n    editor: {\n      primary: \"hsl(177, 100%, 55%)\",\n      secondary: \"hsl(280, 20%, 60%)\",\n      info: \"hsl(320, 100%, 75%)\",\n      success: \"hsl(83, 100%, 60%)\",\n      notice: \"hsl(57, 100%, 60%)\",\n      warning: \"hsl(30, 100%, 60%)\",\n      danger: \"hsl(340, 100%, 65%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/tokyo-night.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const tokyoNight: Theme = {\n  id: \"tokyo-night\",\n  label: \"Tokyo Night\",\n  dark: true,\n  base: {\n    surface: \"hsl(235, 21%, 13%)\",\n    surfaceHighlight: \"hsl(235, 18%, 18%)\",\n    text: \"hsl(229, 28%, 76%)\",\n    textSubtle: \"hsl(232, 18%, 52%)\",\n    textSubtlest: \"hsl(234, 16%, 40%)\",\n    primary: \"hsl(266, 100%, 78%)\",\n    secondary: \"hsl(232, 18%, 52%)\",\n    info: \"hsl(217, 100%, 73%)\",\n    success: \"hsl(158, 57%, 63%)\",\n    notice: \"hsl(40, 67%, 65%)\",\n    warning: \"hsl(25, 75%, 58%)\",\n    danger: \"hsl(358, 100%, 70%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(235, 21%, 11%)\",\n    },\n    sidebar: {\n      surface: \"hsl(235, 21%, 11%)\",\n      border: \"hsl(235, 18%, 16%)\",\n    },\n    appHeader: {\n      surface: \"hsl(235, 21%, 9%)\",\n      border: \"hsl(235, 18%, 14%)\",\n    },\n    responsePane: {\n      surface: \"hsl(235, 21%, 11%)\",\n      border: \"hsl(235, 18%, 16%)\",\n    },\n    button: {\n      primary: \"hsl(266, 100%, 71%)\",\n      info: \"hsl(217, 100%, 66%)\",\n      success: \"hsl(158, 57%, 56%)\",\n      notice: \"hsl(40, 67%, 58%)\",\n      warning: \"hsl(25, 75%, 52%)\",\n      danger: \"hsl(358, 100%, 63%)\",\n    },\n  },\n};\n\nexport const tokyoNightStorm: Theme = {\n  id: \"tokyo-night-storm\",\n  label: \"Tokyo Night Storm\",\n  dark: true,\n  base: {\n    surface: \"hsl(232, 25%, 17%)\",\n    surfaceHighlight: \"hsl(232, 22%, 22%)\",\n    text: \"hsl(229, 28%, 76%)\",\n    textSubtle: \"hsl(232, 18%, 52%)\",\n    textSubtlest: \"hsl(234, 16%, 40%)\",\n    primary: \"hsl(266, 100%, 78%)\",\n    secondary: \"hsl(232, 18%, 52%)\",\n    info: \"hsl(217, 100%, 73%)\",\n    success: \"hsl(158, 57%, 63%)\",\n    notice: \"hsl(40, 67%, 65%)\",\n    warning: \"hsl(25, 75%, 58%)\",\n    danger: \"hsl(358, 100%, 70%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(232, 25%, 14%)\",\n    },\n    sidebar: {\n      surface: \"hsl(232, 25%, 14%)\",\n      border: \"hsl(232, 22%, 20%)\",\n    },\n    appHeader: {\n      surface: \"hsl(232, 25%, 12%)\",\n      border: \"hsl(232, 22%, 18%)\",\n    },\n    responsePane: {\n      surface: \"hsl(232, 25%, 14%)\",\n      border: \"hsl(232, 22%, 20%)\",\n    },\n    button: {\n      primary: \"hsl(266, 100%, 71%)\",\n      info: \"hsl(217, 100%, 66%)\",\n      success: \"hsl(158, 57%, 56%)\",\n      notice: \"hsl(40, 67%, 58%)\",\n      warning: \"hsl(25, 75%, 52%)\",\n      danger: \"hsl(358, 100%, 63%)\",\n    },\n  },\n};\n\nexport const tokyoNightDay: Theme = {\n  id: \"tokyo-night-day\",\n  label: \"Tokyo Night Day\",\n  dark: false,\n  base: {\n    surface: \"hsl(212, 100%, 98%)\",\n    surfaceHighlight: \"hsl(212, 60%, 93%)\",\n    text: \"hsl(233, 26%, 27%)\",\n    textSubtle: \"hsl(232, 18%, 45%)\",\n    textSubtlest: \"hsl(232, 12%, 55%)\",\n    primary: \"hsl(290, 80%, 45%)\",\n    secondary: \"hsl(232, 18%, 50%)\",\n    info: \"hsl(217, 88%, 52%)\",\n    success: \"hsl(160, 75%, 35%)\",\n    notice: \"hsl(41, 80%, 40%)\",\n    warning: \"hsl(20, 80%, 48%)\",\n    danger: \"hsl(359, 65%, 48%)\",\n  },\n  components: {\n    sidebar: {\n      surface: \"hsl(212, 60%, 95%)\",\n      border: \"hsl(212, 40%, 88%)\",\n    },\n    appHeader: {\n      surface: \"hsl(212, 60%, 93%)\",\n      border: \"hsl(212, 40%, 86%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/triangle.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const triangle: Theme = {\n  id: \"triangle\",\n  dark: true,\n  label: \"Triangle\",\n  base: {\n    surface: \"rgb(0,0,0)\",\n    surfaceHighlight: \"rgb(21,21,21)\",\n    surfaceActive: \"rgb(31,31,31)\",\n    text: \"rgb(237,237,237)\",\n    textSubtle: \"rgb(161,161,161)\",\n    textSubtlest: \"rgb(115,115,115)\",\n    border: \"rgb(31,31,31)\",\n    primary: \"rgb(196,114,251)\",\n    secondary: \"rgb(161,161,161)\",\n    info: \"rgb(71,168,255)\",\n    success: \"rgb(0,202,81)\",\n    notice: \"rgb(255,175,0)\",\n    warning: \"#FF4C8D\",\n    danger: \"#fd495a\",\n  },\n  components: {\n    editor: {\n      danger: \"#FF4C8D\",\n      warning: \"#fd495a\",\n    },\n    dialog: {\n      surface: \"rgb(10,10,10)\",\n      border: \"rgb(31,31,31)\",\n    },\n    sidebar: {\n      border: \"rgb(31,31,31)\",\n    },\n    responsePane: {\n      surface: \"rgb(10,10,10)\",\n      border: \"rgb(31,31,31)\",\n    },\n    appHeader: {\n      surface: \"rgb(10,10,10)\",\n      border: \"rgb(31,31,31)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/vitesse.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const vitesseDark: Theme = {\n  id: \"vitesse-dark\",\n  label: \"Vitesse Dark\",\n  dark: true,\n  base: {\n    surface: \"hsl(220, 13%, 10%)\",\n    surfaceHighlight: \"hsl(220, 12%, 15%)\",\n    text: \"hsl(220, 10%, 80%)\",\n    textSubtle: \"hsl(220, 8%, 55%)\",\n    textSubtlest: \"hsl(220, 6%, 42%)\",\n    primary: \"hsl(143, 50%, 55%)\",\n    secondary: \"hsl(220, 8%, 55%)\",\n    info: \"hsl(214, 60%, 65%)\",\n    success: \"hsl(143, 50%, 55%)\",\n    notice: \"hsl(45, 65%, 65%)\",\n    warning: \"hsl(30, 60%, 60%)\",\n    danger: \"hsl(355, 60%, 60%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(220, 13%, 7%)\",\n    },\n    sidebar: {\n      surface: \"hsl(220, 12%, 8%)\",\n      border: \"hsl(220, 10%, 14%)\",\n    },\n    appHeader: {\n      surface: \"hsl(220, 13%, 6%)\",\n      border: \"hsl(220, 10%, 12%)\",\n    },\n    responsePane: {\n      surface: \"hsl(220, 12%, 8%)\",\n      border: \"hsl(220, 10%, 14%)\",\n    },\n    button: {\n      primary: \"hsl(143, 50%, 48%)\",\n      secondary: \"hsl(220, 8%, 48%)\",\n      info: \"hsl(214, 60%, 58%)\",\n      success: \"hsl(143, 50%, 48%)\",\n      notice: \"hsl(45, 65%, 58%)\",\n      warning: \"hsl(30, 60%, 53%)\",\n      danger: \"hsl(355, 60%, 53%)\",\n    },\n  },\n};\n\nexport const vitesseLight: Theme = {\n  id: \"vitesse-light\",\n  label: \"Vitesse Light\",\n  dark: false,\n  base: {\n    surface: \"hsl(0, 0%, 100%)\",\n    surfaceHighlight: \"hsl(40, 20%, 96%)\",\n    text: \"hsl(0, 0%, 24%)\",\n    textSubtle: \"hsl(0, 0%, 45%)\",\n    textSubtlest: \"hsl(0, 0%, 55%)\",\n    primary: \"hsl(143, 40%, 40%)\",\n    secondary: \"hsl(0, 0%, 45%)\",\n    info: \"hsl(214, 50%, 48%)\",\n    success: \"hsl(143, 40%, 40%)\",\n    notice: \"hsl(40, 60%, 42%)\",\n    warning: \"hsl(25, 60%, 48%)\",\n    danger: \"hsl(345, 50%, 48%)\",\n  },\n  components: {\n    sidebar: {\n      surface: \"hsl(40, 20%, 97%)\",\n      border: \"hsl(40, 15%, 92%)\",\n    },\n    appHeader: {\n      surface: \"hsl(40, 20%, 95%)\",\n      border: \"hsl(40, 15%, 90%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/src/themes/winter-is-coming.ts",
    "content": "import type { Theme } from \"@yaakapp/api\";\n\nexport const winterIsComing: Theme = {\n  id: \"winter-is-coming\",\n  label: \"Winter is Coming\",\n  dark: true,\n  base: {\n    surface: \"hsl(216, 50%, 10%)\",\n    surfaceHighlight: \"hsl(216, 40%, 15%)\",\n    text: \"hsl(210, 20%, 88%)\",\n    textSubtle: \"hsl(210, 15%, 60%)\",\n    textSubtlest: \"hsl(210, 10%, 45%)\",\n    primary: \"hsl(176, 85%, 60%)\",\n    secondary: \"hsl(210, 15%, 60%)\",\n    info: \"hsl(210, 65%, 65%)\",\n    success: \"hsl(100, 65%, 55%)\",\n    notice: \"hsl(45, 100%, 65%)\",\n    warning: \"hsl(30, 90%, 55%)\",\n    danger: \"hsl(350, 100%, 65%)\",\n  },\n  components: {\n    dialog: {\n      surface: \"hsl(216, 50%, 7%)\",\n    },\n    sidebar: {\n      surface: \"hsl(216, 45%, 12%)\",\n      border: \"hsl(216, 40%, 17%)\",\n    },\n    appHeader: {\n      surface: \"hsl(216, 50%, 8%)\",\n      border: \"hsl(216, 40%, 14%)\",\n    },\n    responsePane: {\n      surface: \"hsl(216, 45%, 12%)\",\n      border: \"hsl(216, 40%, 17%)\",\n    },\n    button: {\n      primary: \"hsl(176, 85%, 53%)\",\n      secondary: \"hsl(210, 15%, 53%)\",\n      info: \"hsl(210, 65%, 58%)\",\n      success: \"hsl(100, 65%, 48%)\",\n      notice: \"hsl(45, 100%, 58%)\",\n      warning: \"hsl(30, 90%, 48%)\",\n      danger: \"hsl(350, 100%, 58%)\",\n    },\n  },\n};\n"
  },
  {
    "path": "plugins/themes-yaak/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins-external/.gitignore",
    "content": "*/build\n"
  },
  {
    "path": "plugins-external/faker/README.md",
    "content": "# Yaak Faker Plugin\n\nThis is a template function that generates realistic fake data\nfor testing and development using [FakerJS](https://fakerjs.dev).\n\n![CleanShot 2024-09-19 at 13 56 33@2x](https://github.com/user-attachments/assets/2f935110-4af2-4236-a50d-18db5454176d)\n\n## Example JSON Body\n\nHere's an example JSON body that uses fake data:\n\n```json\n{\n  \"id\": \"${[ faker.string.uuid() ]}\",\n  \"name\": \"${[ faker.person.fullName() ]}\",\n  \"email\": \"${[ faker.internet.email() ]}\",\n  \"phone\": \"${[ faker.phone.number() ]}\",\n  \"address\": {\n    \"street\": \"${[ faker.location.streetAddress() ]}\",\n    \"city\": \"${[ faker.location.city() ]}\",\n    \"country\": \"${[ faker.location.country() ]}\",\n    \"zipCode\": \"${[ faker.location.zipCode() ]}\"\n  },\n  \"company\": \"${[ faker.company.name() ]}\",\n  \"website\": \"${[ faker.internet.url() ]}\"\n}\n```\n\nThis will generate a random JSON body on every request:\n\n```json\n{\n  \"id\": \"589f0aec-7310-4bf2-81c4-0b1bb7f1c3c1\",\n  \"name\": \"Lucy Gottlieb-Weissnat\",\n  \"email\": \"Destiny_Herzog@gmail.com\",\n  \"phone\": \"411.805.2871 x699\",\n  \"address\": {\n    \"street\": \"846 Christ Mills\",\n    \"city\": \"Spencerfurt\",\n    \"country\": \"United Kingdom\",\n    \"zipCode\": \"20354\"\n  },\n  \"company\": \"Emard, Kohler and Rutherford\",\n  \"website\": \"https://watery-detective.org\"\n}\n```\n\n## Available Categories\n\nThe plugin provides access to all FakerJS modules and their methods:\n\n| Category   | Description               | Example Methods                            |\n| ---------- | ------------------------- | ------------------------------------------ |\n| `airline`  | Airline-related data      | `aircraftType`, `airline`, `airplane`      |\n| `animal`   | Animal names and types    | `bear`, `bird`, `cat`, `dog`, `fish`       |\n| `color`    | Colors in various formats | `human`, `rgb`, `hex`, `hsl`               |\n| `commerce` | E-commerce data           | `department`, `product`, `price`           |\n| `company`  | Company information       | `name`, `catchPhrase`, `bs`                |\n| `database` | Database-related data     | `column`, `type`, `collation`              |\n| `date`     | Date and time values      | `recent`, `future`, `past`, `between`      |\n| `finance`  | Financial data            | `account`, `amount`, `currency`            |\n| `git`      | Git-related data          | `branch`, `commitEntry`, `commitSha`       |\n| `hacker`   | Tech/hacker terminology   | `abbreviation`, `noun`, `phrase`           |\n| `image`    | Image URLs and data       | `avatar`, `url`, `dataUri`                 |\n| `internet` | Internet-related data     | `email`, `url`, `ip`, `userAgent`          |\n| `location` | Geographic data           | `city`, `country`, `latitude`, `longitude` |\n| `lorem`    | Lorem ipsum text          | `word`, `sentence`, `paragraph`            |\n| `person`   | Personal information      | `firstName`, `lastName`, `fullName`        |\n| `music`    | Music-related data        | `genre`, `songName`, `artist`              |\n| `number`   | Numeric data              | `int`, `float`, `binary`, `hex`            |\n| `phone`    | Phone numbers             | `number`, `imei`                           |\n| `science`  | Scientific data           | `chemicalElement`, `unit`                  |\n| `string`   | String utilities          | `uuid`, `alpha`, `alphanumeric`            |\n| `system`   | System-related data       | `fileName`, `mimeType`, `fileExt`          |\n| `vehicle`  | Vehicle information       | `vehicle`, `manufacturer`, `model`         |\n| `word`     | Word generation           | `adjective`, `adverb`, `conjunction`       |\n"
  },
  {
    "path": "plugins-external/faker/package.json",
    "content": "{\n  \"name\": \"@yaak/faker\",\n  \"displayName\": \"Faker\",\n  \"version\": \"1.1.1\",\n  \"private\": true,\n  \"description\": \"Template functions for generating fake data using FakerJS\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins-external/faker\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\",\n    \"test\": \"vp test --run tests\"\n  },\n  \"dependencies\": {\n    \"@faker-js/faker\": \"^10.1.0\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^25.0.3\",\n    \"typescript\": \"^5.9.3\"\n  }\n}\n"
  },
  {
    "path": "plugins-external/faker/src/index.ts",
    "content": "import { faker } from \"@faker-js/faker\";\nimport type { DynamicTemplateFunctionArg, PluginDefinition } from \"@yaakapp/api\";\n\nconst modules = [\n  \"airline\",\n  \"animal\",\n  \"color\",\n  \"commerce\",\n  \"company\",\n  \"database\",\n  \"date\",\n  \"finance\",\n  \"git\",\n  \"hacker\",\n  \"image\",\n  \"internet\",\n  \"location\",\n  \"lorem\",\n  \"person\",\n  \"music\",\n  \"number\",\n  \"phone\",\n  \"science\",\n  \"string\",\n  \"system\",\n  \"vehicle\",\n  \"word\",\n];\n\nfunction normalizeResult(result: unknown): string {\n  if (typeof result === \"string\") return result;\n  if (result instanceof Date) return result.toISOString();\n  return JSON.stringify(result);\n}\n\n// Whatever Yaak’s arg type shape is – rough example\nfunction args(modName: string, fnName: string): DynamicTemplateFunctionArg[] {\n  return [\n    {\n      type: \"banner\",\n      color: \"info\",\n      inputs: [\n        {\n          type: \"markdown\",\n          content: `Need help? View documentation for [\\`${modName}.${fnName}(…)\\`](https://fakerjs.dev/api/${encodeURIComponent(modName)}.html#${encodeURIComponent(fnName)})`,\n        },\n      ],\n    },\n    {\n      name: \"options\",\n      label: \"Arguments\",\n      type: \"editor\",\n      language: \"json\",\n      optional: true,\n      placeholder: 'e.g. { \"min\": 1, \"max\": 10 } or 10 or [\"en\",\"US\"]',\n    },\n  ];\n}\n\nexport const plugin: PluginDefinition = {\n  templateFunctions: modules.flatMap((modName) => {\n    const mod = faker[modName as keyof typeof faker];\n    return Object.keys(mod)\n      .filter((n) => n !== \"faker\")\n      .map((fnName) => ({\n        name: [\"faker\", modName, fnName].join(\".\"),\n        args: args(modName, fnName),\n        async onRender(_ctx, args) {\n          const fn = mod[fnName as keyof typeof mod] as (...a: unknown[]) => unknown;\n          const options = args.values.options;\n\n          // No options supplied\n          if (options == null || options === \"\") {\n            return normalizeResult(fn());\n          }\n\n          // Try JSON first\n          let parsed: unknown = options;\n          if (typeof options === \"string\") {\n            try {\n              parsed = JSON.parse(options);\n            } catch {\n              // Not valid JSON – maybe just a scalar\n              const n = Number(options);\n              if (!Number.isNaN(n)) {\n                parsed = n;\n              } else {\n                parsed = options;\n              }\n            }\n          }\n\n          let result: unknown;\n          if (Array.isArray(parsed)) {\n            // Treat as positional arguments\n            result = fn(...parsed);\n          } else {\n            // Treat as a single argument (option object or scalar)\n            result = fn(parsed);\n          }\n\n          return normalizeResult(result);\n        },\n      }));\n  }),\n};\n"
  },
  {
    "path": "plugins-external/faker/tests/__snapshots__/init.test.ts.snap",
    "content": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`template-function-faker > exports all expected template functions 1`] = `\n[\n  \"faker.airline.aircraftType\",\n  \"faker.airline.airline\",\n  \"faker.airline.airplane\",\n  \"faker.airline.airport\",\n  \"faker.airline.flightNumber\",\n  \"faker.airline.recordLocator\",\n  \"faker.airline.seat\",\n  \"faker.animal.bear\",\n  \"faker.animal.bird\",\n  \"faker.animal.cat\",\n  \"faker.animal.cetacean\",\n  \"faker.animal.cow\",\n  \"faker.animal.crocodilia\",\n  \"faker.animal.dog\",\n  \"faker.animal.fish\",\n  \"faker.animal.horse\",\n  \"faker.animal.insect\",\n  \"faker.animal.lion\",\n  \"faker.animal.petName\",\n  \"faker.animal.rabbit\",\n  \"faker.animal.rodent\",\n  \"faker.animal.snake\",\n  \"faker.animal.type\",\n  \"faker.color.cmyk\",\n  \"faker.color.colorByCSSColorSpace\",\n  \"faker.color.cssSupportedFunction\",\n  \"faker.color.cssSupportedSpace\",\n  \"faker.color.hsl\",\n  \"faker.color.human\",\n  \"faker.color.hwb\",\n  \"faker.color.lab\",\n  \"faker.color.lch\",\n  \"faker.color.rgb\",\n  \"faker.color.space\",\n  \"faker.commerce.department\",\n  \"faker.commerce.isbn\",\n  \"faker.commerce.price\",\n  \"faker.commerce.product\",\n  \"faker.commerce.productAdjective\",\n  \"faker.commerce.productDescription\",\n  \"faker.commerce.productMaterial\",\n  \"faker.commerce.productName\",\n  \"faker.commerce.upc\",\n  \"faker.company.buzzAdjective\",\n  \"faker.company.buzzNoun\",\n  \"faker.company.buzzPhrase\",\n  \"faker.company.buzzVerb\",\n  \"faker.company.catchPhrase\",\n  \"faker.company.catchPhraseAdjective\",\n  \"faker.company.catchPhraseDescriptor\",\n  \"faker.company.catchPhraseNoun\",\n  \"faker.company.name\",\n  \"faker.database.collation\",\n  \"faker.database.column\",\n  \"faker.database.engine\",\n  \"faker.database.mongodbObjectId\",\n  \"faker.database.type\",\n  \"faker.date.anytime\",\n  \"faker.date.between\",\n  \"faker.date.betweens\",\n  \"faker.date.birthdate\",\n  \"faker.date.future\",\n  \"faker.date.month\",\n  \"faker.date.past\",\n  \"faker.date.recent\",\n  \"faker.date.soon\",\n  \"faker.date.timeZone\",\n  \"faker.date.weekday\",\n  \"faker.finance.accountName\",\n  \"faker.finance.accountNumber\",\n  \"faker.finance.amount\",\n  \"faker.finance.bic\",\n  \"faker.finance.bitcoinAddress\",\n  \"faker.finance.creditCardCVV\",\n  \"faker.finance.creditCardIssuer\",\n  \"faker.finance.creditCardNumber\",\n  \"faker.finance.currency\",\n  \"faker.finance.currencyCode\",\n  \"faker.finance.currencyName\",\n  \"faker.finance.currencyNumericCode\",\n  \"faker.finance.currencySymbol\",\n  \"faker.finance.ethereumAddress\",\n  \"faker.finance.iban\",\n  \"faker.finance.litecoinAddress\",\n  \"faker.finance.pin\",\n  \"faker.finance.routingNumber\",\n  \"faker.finance.transactionDescription\",\n  \"faker.finance.transactionType\",\n  \"faker.git.branch\",\n  \"faker.git.commitDate\",\n  \"faker.git.commitEntry\",\n  \"faker.git.commitMessage\",\n  \"faker.git.commitSha\",\n  \"faker.hacker.abbreviation\",\n  \"faker.hacker.adjective\",\n  \"faker.hacker.ingverb\",\n  \"faker.hacker.noun\",\n  \"faker.hacker.phrase\",\n  \"faker.hacker.verb\",\n  \"faker.image.avatar\",\n  \"faker.image.avatarGitHub\",\n  \"faker.image.dataUri\",\n  \"faker.image.personPortrait\",\n  \"faker.image.url\",\n  \"faker.image.urlLoremFlickr\",\n  \"faker.image.urlPicsumPhotos\",\n  \"faker.internet.displayName\",\n  \"faker.internet.domainName\",\n  \"faker.internet.domainSuffix\",\n  \"faker.internet.domainWord\",\n  \"faker.internet.email\",\n  \"faker.internet.emoji\",\n  \"faker.internet.exampleEmail\",\n  \"faker.internet.httpMethod\",\n  \"faker.internet.httpStatusCode\",\n  \"faker.internet.ip\",\n  \"faker.internet.ipv4\",\n  \"faker.internet.ipv6\",\n  \"faker.internet.jwt\",\n  \"faker.internet.jwtAlgorithm\",\n  \"faker.internet.mac\",\n  \"faker.internet.password\",\n  \"faker.internet.port\",\n  \"faker.internet.protocol\",\n  \"faker.internet.url\",\n  \"faker.internet.userAgent\",\n  \"faker.internet.username\",\n  \"faker.location.buildingNumber\",\n  \"faker.location.cardinalDirection\",\n  \"faker.location.city\",\n  \"faker.location.continent\",\n  \"faker.location.country\",\n  \"faker.location.countryCode\",\n  \"faker.location.county\",\n  \"faker.location.direction\",\n  \"faker.location.language\",\n  \"faker.location.latitude\",\n  \"faker.location.longitude\",\n  \"faker.location.nearbyGPSCoordinate\",\n  \"faker.location.ordinalDirection\",\n  \"faker.location.secondaryAddress\",\n  \"faker.location.state\",\n  \"faker.location.street\",\n  \"faker.location.streetAddress\",\n  \"faker.location.timeZone\",\n  \"faker.location.zipCode\",\n  \"faker.lorem.lines\",\n  \"faker.lorem.paragraph\",\n  \"faker.lorem.paragraphs\",\n  \"faker.lorem.sentence\",\n  \"faker.lorem.sentences\",\n  \"faker.lorem.slug\",\n  \"faker.lorem.text\",\n  \"faker.lorem.word\",\n  \"faker.lorem.words\",\n  \"faker.music.album\",\n  \"faker.music.artist\",\n  \"faker.music.genre\",\n  \"faker.music.songName\",\n  \"faker.number.bigInt\",\n  \"faker.number.binary\",\n  \"faker.number.float\",\n  \"faker.number.hex\",\n  \"faker.number.int\",\n  \"faker.number.octal\",\n  \"faker.number.romanNumeral\",\n  \"faker.person.bio\",\n  \"faker.person.firstName\",\n  \"faker.person.fullName\",\n  \"faker.person.gender\",\n  \"faker.person.jobArea\",\n  \"faker.person.jobDescriptor\",\n  \"faker.person.jobTitle\",\n  \"faker.person.jobType\",\n  \"faker.person.lastName\",\n  \"faker.person.middleName\",\n  \"faker.person.prefix\",\n  \"faker.person.sex\",\n  \"faker.person.sexType\",\n  \"faker.person.suffix\",\n  \"faker.person.zodiacSign\",\n  \"faker.phone.imei\",\n  \"faker.phone.number\",\n  \"faker.science.chemicalElement\",\n  \"faker.science.unit\",\n  \"faker.string.alpha\",\n  \"faker.string.alphanumeric\",\n  \"faker.string.binary\",\n  \"faker.string.fromCharacters\",\n  \"faker.string.hexadecimal\",\n  \"faker.string.nanoid\",\n  \"faker.string.numeric\",\n  \"faker.string.octal\",\n  \"faker.string.sample\",\n  \"faker.string.symbol\",\n  \"faker.string.ulid\",\n  \"faker.string.uuid\",\n  \"faker.system.commonFileExt\",\n  \"faker.system.commonFileName\",\n  \"faker.system.commonFileType\",\n  \"faker.system.cron\",\n  \"faker.system.directoryPath\",\n  \"faker.system.fileExt\",\n  \"faker.system.fileName\",\n  \"faker.system.filePath\",\n  \"faker.system.fileType\",\n  \"faker.system.mimeType\",\n  \"faker.system.networkInterface\",\n  \"faker.system.semver\",\n  \"faker.vehicle.bicycle\",\n  \"faker.vehicle.color\",\n  \"faker.vehicle.fuel\",\n  \"faker.vehicle.manufacturer\",\n  \"faker.vehicle.model\",\n  \"faker.vehicle.type\",\n  \"faker.vehicle.vehicle\",\n  \"faker.vehicle.vin\",\n  \"faker.vehicle.vrm\",\n  \"faker.word.adjective\",\n  \"faker.word.adverb\",\n  \"faker.word.conjunction\",\n  \"faker.word.interjection\",\n  \"faker.word.noun\",\n  \"faker.word.preposition\",\n  \"faker.word.sample\",\n  \"faker.word.verb\",\n  \"faker.word.words\",\n]\n`;\n"
  },
  {
    "path": "plugins-external/faker/tests/init.test.ts",
    "content": "import { describe, expect, it } from \"vite-plus/test\";\n\ndescribe(\"template-function-faker\", () => {\n  it(\"exports all expected template functions\", async () => {\n    const { plugin } = await import(\"../src/index\");\n    const names = plugin.templateFunctions?.map((fn) => fn.name).sort() ?? [];\n\n    // Snapshot the full list of exported function names so we catch any\n    // accidental additions, removals, or renames across faker upgrades.\n    expect(names).toMatchSnapshot();\n  });\n\n  it(\"renders date results as unquoted ISO strings\", async () => {\n    const { plugin } = await import(\"../src/index\");\n    const fn = plugin.templateFunctions?.find((fn) => fn.name === \"faker.date.future\");\n    // oxlint-disable-next-line unbound-method\n    const onRender = fn?.onRender;\n\n    expect(onRender).toBeTypeOf(\"function\");\n    if (onRender == null) {\n      throw new Error(\"Expected template function 'faker.date.future' to define onRender\");\n    }\n\n    const result = await onRender(\n      {} as Parameters<typeof onRender>[0],\n      { values: {} } as Parameters<typeof onRender>[1],\n    );\n\n    expect(result).toMatch(/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/);\n  });\n});\n"
  },
  {
    "path": "plugins-external/faker/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\"\n}\n"
  },
  {
    "path": "plugins-external/httpsnippet/README.md",
    "content": "# Yaak HTTP Snippet Plugin\n\nGenerate code snippets for HTTP requests in various languages and frameworks,\npowered by [@readme/httpsnippet](https://github.com/readmeio/httpsnippet).\n\n![Httpsnippet plugin](https://assets.yaak.app/uploads/httpsnippet-guiaX_1786x1420.png)\n\n## How It Works\n\nRight-click any HTTP request (or use the `...` menu) and select **Generate Code Snippet**.\nA dialog lets you pick a language and library, with a live preview of the generated code.\nClick **Copy to Clipboard** to copy the snippet. Your language and library selections are\nremembered for next time.\n\n## Supported Languages\n\nEach language supports one or more libraries:\n\n| Language    | Libraries                            |\n| ----------- | ------------------------------------ |\n| C           | libcurl                              |\n| Clojure     | clj-http                             |\n| C#          | HttpClient, RestSharp                |\n| Go          | Native                               |\n| HTTP        | HTTP/1.1                             |\n| Java        | AsyncHttp, NetHttp, OkHttp, Unirest  |\n| JavaScript  | Axios, fetch, jQuery, XHR            |\n| Kotlin      | OkHttp                               |\n| Node.js     | Axios, fetch, HTTP, Request, Unirest |\n| Objective-C | NSURLSession                         |\n| OCaml       | CoHTTP                               |\n| PHP         | cURL, Guzzle, HTTP v1, HTTP v2       |\n| PowerShell  | Invoke-WebRequest, RestMethod        |\n| Python      | http.client, Requests                |\n| R           | httr                                 |\n| Ruby        | Native                               |\n| Shell       | cURL, HTTPie, Wget                   |\n| Swift       | URLSession                           |\n\n## Features\n\n- Renders template variables before generating snippets, so the output reflects real values\n- Supports all body types: JSON, form-urlencoded, multipart, GraphQL, and raw text\n- Includes authentication headers (Basic, Bearer, and API Key)\n- Includes query parameters and custom headers\n"
  },
  {
    "path": "plugins-external/httpsnippet/package.json",
    "content": "{\n  \"name\": \"@yaak/httpsnippet\",\n  \"displayName\": \"HTTP Snippet\",\n  \"version\": \"1.0.3\",\n  \"private\": true,\n  \"description\": \"Generate code snippets for HTTP requests in various languages and frameworks\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins-external/httpsnippet\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  },\n  \"dependencies\": {\n    \"@readme/httpsnippet\": \"^11.0.0\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^22.0.0\",\n    \"typescript\": \"^5.9.3\"\n  },\n  \"minYaakVersion\": \"2026.2.0-beta.10\"\n}\n"
  },
  {
    "path": "plugins-external/httpsnippet/src/index.ts",
    "content": "import { availableTargets, type HarRequest, HTTPSnippet } from \"@readme/httpsnippet\";\nimport type { EditorLanguage, HttpRequest, PluginDefinition } from \"@yaakapp/api\";\n\n// Get all available targets and build select options\nconst targets = availableTargets();\n\n// Targets to exclude from the language list\nconst excludedTargets = new Set([\"json\"]);\n\n// Build language (target) options\nconst languageOptions = targets\n  .filter((target) => !excludedTargets.has(target.key))\n  .map((target) => ({\n    label: target.title,\n    value: target.key,\n  }));\n\n// Preferred clients per target (shown first in the list)\nconst preferredClients: Record<string, string> = {\n  javascript: \"fetch\",\n  node: \"fetch\",\n};\n\n// Get client options for a given target key\nfunction getClientOptions(targetKey: string) {\n  const target = targets.find((t) => t.key === targetKey);\n  if (!target) return [];\n  const preferred = preferredClients[targetKey];\n  return target.clients\n    .map((client) => ({\n      label: client.title,\n      value: client.key,\n    }))\n    .sort((a, b) => {\n      if (a.value === preferred) return -1;\n      if (b.value === preferred) return 1;\n      return 0;\n    });\n}\n\n// Get default client for a target\nfunction getDefaultClient(targetKey: string): string {\n  const options = getClientOptions(targetKey);\n  return options[0]?.value ?? \"\";\n}\n\n// Defaults\nconst defaultTarget = \"javascript\";\n\n// Map httpsnippet target key to editor language for syntax highlighting\nconst editorLanguageMap: Record<string, EditorLanguage> = {\n  c: \"c\",\n  clojure: \"clojure\",\n  csharp: \"csharp\",\n  go: \"go\",\n  http: \"http\",\n  java: \"java\",\n  javascript: \"javascript\",\n  kotlin: \"kotlin\",\n  node: \"javascript\",\n  objc: \"objective_c\",\n  ocaml: \"ocaml\",\n  php: \"php\",\n  powershell: \"powershell\",\n  python: \"python\",\n  r: \"r\",\n  ruby: \"ruby\",\n  shell: \"shell\",\n  swift: \"swift\",\n};\n\nfunction getEditorLanguage(targetKey: string): EditorLanguage {\n  return editorLanguageMap[targetKey] ?? \"text\";\n}\n\n// Convert Yaak HttpRequest to HAR format\nfunction toHarRequest(request: Partial<HttpRequest>) {\n  // Build URL with query parameters\n  let finalUrl = request.url || \"\";\n  const urlParams = (request.urlParameters ?? []).filter((p) => p.enabled !== false && !!p.name);\n  if (urlParams.length > 0) {\n    const [base, hash] = finalUrl.split(\"#\");\n    const separator = base?.includes(\"?\") ? \"&\" : \"?\";\n    const queryString = urlParams\n      .map((p) => `${encodeURIComponent(p.name)}=${encodeURIComponent(p.value)}`)\n      .join(\"&\");\n    finalUrl = base + separator + queryString + (hash ? `#${hash}` : \"\");\n  }\n\n  // Build headers array\n  const headers: Array<{ name: string; value: string }> = (request.headers ?? [])\n    .filter((h) => h.enabled !== false && !!h.name)\n    .map((h) => ({ name: h.name, value: h.value }));\n\n  // Handle authentication\n  if (request.authentication?.disabled !== true) {\n    if (request.authenticationType === \"basic\") {\n      const credentials = btoa(\n        `${request.authentication?.username ?? \"\"}:${request.authentication?.password ?? \"\"}`,\n      );\n      headers.push({ name: \"Authorization\", value: `Basic ${credentials}` });\n    } else if (request.authenticationType === \"bearer\") {\n      const prefix = request.authentication?.prefix ?? \"Bearer\";\n      const token = request.authentication?.token ?? \"\";\n      headers.push({ name: \"Authorization\", value: `${prefix} ${token}`.trim() });\n    } else if (request.authenticationType === \"apikey\") {\n      if (request.authentication?.location === \"header\") {\n        headers.push({\n          name: request.authentication?.key ?? \"X-Api-Key\",\n          value: request.authentication?.value ?? \"\",\n        });\n      } else if (request.authentication?.location === \"query\") {\n        const sep = finalUrl.includes(\"?\") ? \"&\" : \"?\";\n        finalUrl = [\n          finalUrl,\n          sep,\n          encodeURIComponent(request.authentication?.key ?? \"token\"),\n          \"=\",\n          encodeURIComponent(request.authentication?.value ?? \"\"),\n        ].join(\"\");\n      }\n    }\n  }\n\n  // Build HAR request object\n  const har: Record<string, unknown> = {\n    method: request.method || \"GET\",\n    url: finalUrl,\n    headers,\n  };\n\n  // Handle request body\n  const bodyType = request.bodyType ?? \"none\";\n  if (bodyType !== \"none\" && request.body) {\n    if (bodyType === \"application/x-www-form-urlencoded\" && Array.isArray(request.body.form)) {\n      const params = request.body.form\n        .filter((p: { enabled?: boolean; name?: string }) => p.enabled !== false && !!p.name)\n        .map((p: { name: string; value: string }) => ({ name: p.name, value: p.value }));\n      har.postData = {\n        mimeType: \"application/x-www-form-urlencoded\",\n        params,\n      };\n    } else if (bodyType === \"multipart/form-data\" && Array.isArray(request.body.form)) {\n      const params = request.body.form\n        .filter((p: { enabled?: boolean; name?: string }) => p.enabled !== false && !!p.name)\n        .map((p: { name: string; value: string; file?: string; contentType?: string }) => {\n          const param: Record<string, string> = { name: p.name, value: p.value || \"\" };\n          if (p.file) param.fileName = p.file;\n          if (p.contentType) param.contentType = p.contentType;\n          return param;\n        });\n      har.postData = {\n        mimeType: \"multipart/form-data\",\n        params,\n      };\n    } else if (bodyType === \"graphql\" && typeof request.body.query === \"string\") {\n      const body = {\n        query: request.body.query || \"\",\n        variables: maybeParseJSON(request.body.variables, undefined),\n      };\n      har.postData = {\n        mimeType: \"application/json\",\n        text: JSON.stringify(body),\n      };\n    } else if (typeof request.body.text === \"string\") {\n      har.postData = {\n        mimeType: bodyType,\n        text: request.body.text,\n      };\n    }\n  }\n\n  return har;\n}\n\nfunction maybeParseJSON<T>(v: unknown, fallback: T): unknown {\n  if (typeof v !== \"string\") return fallback;\n  try {\n    return JSON.parse(v);\n  } catch {\n    return fallback;\n  }\n}\n\nexport const plugin: PluginDefinition = {\n  httpRequestActions: [\n    {\n      label: \"Generate Code Snippet\",\n      icon: \"copy\",\n      async onSelect(ctx, args) {\n        // Render the request with variables resolved\n        const renderedRequest = await ctx.httpRequest.render({\n          httpRequest: args.httpRequest,\n          purpose: \"send\",\n        });\n\n        // Convert to HAR format\n        const harRequest = toHarRequest(renderedRequest) as HarRequest;\n\n        // Get previously selected language or use defaults\n        const storedTarget = await ctx.store.get<string>(\"selectedTarget\");\n        const initialTarget = storedTarget || defaultTarget;\n        const storedClient = await ctx.store.get<string>(`selectedClient:${initialTarget}`);\n        const initialClient = storedClient || getDefaultClient(initialTarget);\n\n        // Create snippet generator\n        const snippet = new HTTPSnippet(harRequest);\n        const generateSnippet = (target: string, client: string): string => {\n          const result = snippet.convert(\n            target as Parameters<typeof snippet.convert>[0],\n            client as Parameters<typeof snippet.convert>[1],\n          );\n          return (Array.isArray(result) ? result.join(\"\\n\") : result || \"\").replace(/\\r\\n/g, \"\\n\");\n        };\n\n        // Generate initial code preview\n        let initialCode = \"\";\n        try {\n          initialCode = generateSnippet(initialTarget, initialClient);\n        } catch {\n          initialCode = \"// Error generating snippet\";\n        }\n\n        // Show dialog with language/library selectors and code preview\n        const result = await ctx.prompt.form({\n          id: \"httpsnippet\",\n          title: \"Generate Code Snippet\",\n          confirmText: \"Copy to Clipboard\",\n          cancelText: \"Cancel\",\n          size: \"md\",\n          inputs: [\n            {\n              type: \"h_stack\",\n              inputs: [\n                {\n                  type: \"select\",\n                  name: \"target\",\n                  label: \"Language\",\n                  defaultValue: initialTarget,\n                  options: languageOptions,\n                },\n                {\n                  type: \"select\",\n                  name: `client-${initialTarget}`,\n                  label: \"Library\",\n                  defaultValue: initialClient,\n                  options: getClientOptions(initialTarget),\n                  dynamic(_ctx, { values }) {\n                    const targetKey = String(values.target || defaultTarget);\n                    const options = getClientOptions(targetKey);\n                    return {\n                      name: `client-${targetKey}`,\n                      options,\n                      defaultValue: options[0]?.value ?? \"\",\n                    };\n                  },\n                },\n              ],\n            },\n            {\n              type: \"editor\",\n              name: \"code\",\n              label: \"Preview\",\n              language: getEditorLanguage(initialTarget),\n              defaultValue: initialCode,\n              readOnly: true,\n              rows: 15,\n              dynamic(_ctx, { values }) {\n                const targetKey = String(values.target || defaultTarget);\n                const clientKey = String(\n                  values[`client-${targetKey}`] || getDefaultClient(targetKey),\n                );\n                let code: string;\n                try {\n                  code = generateSnippet(targetKey, clientKey);\n                } catch {\n                  code = \"// Error generating snippet\";\n                }\n                return {\n                  defaultValue: code,\n                  language: getEditorLanguage(targetKey),\n                };\n              },\n            },\n          ],\n        });\n\n        if (result) {\n          // Store the selected language and library for next time\n          const selectedTarget = String(result.target || initialTarget);\n          const selectedClient = String(\n            result[`client-${selectedTarget}`] || getDefaultClient(selectedTarget),\n          );\n          await ctx.store.set(\"selectedTarget\", selectedTarget);\n          await ctx.store.set(`selectedClient:${selectedTarget}`, selectedClient);\n\n          // Generate snippet for the selected language\n          try {\n            const codeText = generateSnippet(selectedTarget, selectedClient);\n            await ctx.clipboard.copyText(codeText);\n            await ctx.toast.show({\n              message: \"Code snippet copied to clipboard\",\n              icon: \"copy\",\n              color: \"success\",\n            });\n          } catch (err) {\n            await ctx.toast.show({\n              message: `Failed to generate snippet: ${err instanceof Error ? err.message : String(err)}`,\n              icon: \"alert_triangle\",\n              color: \"danger\",\n            });\n          }\n        }\n      },\n    },\n  ],\n};\n"
  },
  {
    "path": "plugins-external/mcp-server/README.md",
    "content": "# Yaak MCP Server Plugin\n\nExposes Yaak's functionality via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/).\n\n## Setup\n\nAdd this to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):\n\n```json\n{\n  \"mcpServers\": {\n    \"yaak\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"mcp-remote\", \"http://127.0.0.1:64343/mcp\"]\n    }\n  }\n}\n```\n\nRestart Claude Desktop and make sure Yaak is running.\n\n## Available Tools\n\n- `list_http_requests` - List all HTTP requests in a workspace\n- `get_http_request` - Get details of a specific HTTP request\n- `send_http_request` - Send an HTTP request and get the response\n- `create_http_request` - Create a new HTTP request\n- `update_http_request` - Update an existing HTTP request\n- `delete_http_request` - Delete an HTTP request\n- `list_folders` - List all folders in a workspace\n- `list_workspaces` - List all open workspaces\n- `get_workspace_id` - Get the current workspace ID\n- `get_environment_id` - Get the current environment ID\n- `copy_to_clipboard` - Copy text to the system clipboard\n- `show_toast` - Show a toast notification in Yaak\n"
  },
  {
    "path": "plugins-external/mcp-server/package.json",
    "content": "{\n  \"name\": \"@yaak/mcp-server\",\n  \"displayName\": \"MCP Server\",\n  \"version\": \"0.2.1\",\n  \"private\": true,\n  \"description\": \"Expose Yaak functionality via Model Context Protocol\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/mountain-loop/yaak.git\",\n    \"directory\": \"plugins-external/mcp-server\"\n  },\n  \"scripts\": {\n    \"build\": \"yaakcli build\",\n    \"dev\": \"yaakcli dev\"\n  },\n  \"dependencies\": {\n    \"@hono/mcp\": \"^0.2.3\",\n    \"@hono/node-server\": \"^1.19.10\",\n    \"@modelcontextprotocol/sdk\": \"^1.26.0\",\n    \"hono\": \"^4.12.4\",\n    \"zod\": \"^3.25.76\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^25.0.3\",\n    \"typescript\": \"^5.9.3\"\n  },\n  \"minYaakVersion\": \"2026.1.0\"\n}\n"
  },
  {
    "path": "plugins-external/mcp-server/src/index.ts",
    "content": "import type { Context, PluginDefinition } from \"@yaakapp/api\";\nimport { createMcpServer } from \"./server.js\";\n\nconst serverPort = parseInt(process.env.YAAK_PLUGIN_MCP_SERVER_PORT ?? \"64343\", 10);\n\nlet mcpServer: Awaited<ReturnType<typeof createMcpServer>> | null = null;\n\nexport const plugin: PluginDefinition = {\n  async init(ctx: Context) {\n    // Start the server after waiting, so there's an active window open to do things\n    // like show the startup toast.\n    console.log(\"Initializing MCP Server plugin\");\n    setTimeout(async () => {\n      try {\n        mcpServer = createMcpServer({ yaak: ctx }, serverPort);\n      } catch (err) {\n        console.error(\"Failed to start MCP server:\", err);\n        void ctx.toast.show({\n          message: `Failed to start MCP Server: ${err instanceof Error ? err.message : String(err)}`,\n          icon: \"alert_triangle\",\n          color: \"danger\",\n          timeout: 10000,\n        });\n      }\n    }, 5000);\n  },\n\n  async dispose() {\n    console.log(\"Disposing MCP Server plugin\");\n\n    if (mcpServer) {\n      await mcpServer.close();\n      mcpServer = null;\n    }\n  },\n};\n"
  },
  {
    "path": "plugins-external/mcp-server/src/server.ts",
    "content": "import { StreamableHTTPTransport } from \"@hono/mcp\";\nimport { serve } from \"@hono/node-server\";\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { Hono } from \"hono\";\nimport { registerFolderTools } from \"./tools/folder.js\";\nimport { registerHttpRequestTools } from \"./tools/httpRequest.js\";\nimport { registerToastTools } from \"./tools/toast.js\";\nimport { registerWindowTools } from \"./tools/window.js\";\nimport { registerWorkspaceTools } from \"./tools/workspace.js\";\nimport type { McpServerContext } from \"./types.js\";\n\nexport function createMcpServer(ctx: McpServerContext, port: number) {\n  console.log(\"Creating MCP server on port\", port);\n  const mcpServer = new McpServer({\n    name: \"yaak-mcp-server\",\n    version: \"0.1.0\",\n  });\n\n  // Register all tools\n  registerToastTools(mcpServer, ctx);\n  registerHttpRequestTools(mcpServer, ctx);\n  registerFolderTools(mcpServer, ctx);\n  registerWindowTools(mcpServer, ctx);\n  registerWorkspaceTools(mcpServer, ctx);\n\n  const app = new Hono();\n  const transport = new StreamableHTTPTransport();\n\n  app.all(\"/mcp\", async (c) => {\n    if (!mcpServer.isConnected()) {\n      // Connect the mcp with the transport\n      await mcpServer.connect(transport);\n      void ctx.yaak.toast.show({\n        message: `MCP Server connected`,\n        icon: \"info\",\n        color: \"info\",\n        timeout: 5000,\n      });\n    }\n    return transport.handleRequest(c);\n  });\n\n  const honoServer = serve(\n    {\n      port,\n      hostname: \"127.0.0.1\",\n      fetch: app.fetch,\n    },\n    (info) => {\n      console.log(\"Started MCP server on \", info.address);\n      void ctx.yaak.toast.show({\n        message: `MCP Server running on http://127.0.0.1:${info.port}`,\n        icon: \"info\",\n        color: \"secondary\",\n        timeout: 10000,\n      });\n    },\n  );\n\n  return {\n    server: mcpServer,\n    close: async () => {\n      await new Promise<void>((resolve, reject) => {\n        honoServer.close((err) => {\n          if (err) reject(err);\n          else resolve();\n        });\n      });\n      await mcpServer.close();\n    },\n  };\n}\n"
  },
  {
    "path": "plugins-external/mcp-server/src/tools/folder.ts",
    "content": "import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport * as z from \"zod\";\nimport type { McpServerContext } from \"../types.js\";\nimport { getWorkspaceContext } from \"./helpers.js\";\nimport {\n  authenticationSchema,\n  authenticationTypeSchema,\n  headersSchema,\n  workspaceIdSchema,\n} from \"./schemas.js\";\n\nexport function registerFolderTools(server: McpServer, ctx: McpServerContext) {\n  server.registerTool(\n    \"list_folders\",\n    {\n      title: \"List Folders\",\n      description: \"List all folders in a workspace\",\n      inputSchema: {\n        workspaceId: workspaceIdSchema,\n      },\n    },\n    async ({ workspaceId }) => {\n      const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);\n      const folders = await workspaceCtx.yaak.folder.list();\n\n      return {\n        content: [\n          {\n            type: \"text\" as const,\n            text: JSON.stringify(folders, null, 2),\n          },\n        ],\n      };\n    },\n  );\n\n  server.registerTool(\n    \"get_folder\",\n    {\n      title: \"Get Folder\",\n      description: \"Get details of a specific folder by ID\",\n      inputSchema: {\n        id: z.string().describe(\"The folder ID\"),\n        workspaceId: workspaceIdSchema,\n      },\n    },\n    async ({ id, workspaceId }) => {\n      const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);\n      const folder = await workspaceCtx.yaak.folder.getById({ id });\n\n      return {\n        content: [\n          {\n            type: \"text\" as const,\n            text: JSON.stringify(folder, null, 2),\n          },\n        ],\n      };\n    },\n  );\n\n  server.registerTool(\n    \"create_folder\",\n    {\n      title: \"Create Folder\",\n      description: \"Create a new folder in a workspace\",\n      inputSchema: {\n        workspaceId: workspaceIdSchema,\n        name: z.string().describe(\"Folder name\"),\n        folderId: z.string().optional().describe(\"Parent folder ID (for nested folders)\"),\n        description: z.string().optional().describe(\"Folder description\"),\n        sortPriority: z.number().optional().describe(\"Sort priority for ordering\"),\n        headers: headersSchema.describe(\"Default headers to apply to requests in this folder\"),\n        authenticationType: authenticationTypeSchema,\n        authentication: authenticationSchema,\n      },\n    },\n    async ({ workspaceId: ogWorkspaceId, ...args }) => {\n      const workspaceCtx = await getWorkspaceContext(ctx, ogWorkspaceId);\n      const workspaceId = await workspaceCtx.yaak.window.workspaceId();\n      if (!workspaceId) {\n        throw new Error(\"No workspace is open\");\n      }\n\n      const folder = await workspaceCtx.yaak.folder.create({\n        workspaceId: workspaceId,\n        ...args,\n      });\n\n      return {\n        content: [{ type: \"text\" as const, text: JSON.stringify(folder, null, 2) }],\n      };\n    },\n  );\n\n  server.registerTool(\n    \"update_folder\",\n    {\n      title: \"Update Folder\",\n      description: \"Update an existing folder\",\n      inputSchema: {\n        id: z.string().describe(\"Folder ID to update\"),\n        workspaceId: workspaceIdSchema,\n        name: z.string().optional().describe(\"Folder name\"),\n        folderId: z.string().optional().describe(\"Parent folder ID (for nested folders)\"),\n        description: z.string().optional().describe(\"Folder description\"),\n        sortPriority: z.number().optional().describe(\"Sort priority for ordering\"),\n        headers: headersSchema.describe(\"Default headers to apply to requests in this folder\"),\n        authenticationType: authenticationTypeSchema,\n        authentication: authenticationSchema,\n      },\n    },\n    async ({ id, workspaceId, ...updates }) => {\n      const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);\n      // Fetch existing folder to merge with updates\n      const existing = await workspaceCtx.yaak.folder.getById({ id });\n      if (!existing) {\n        throw new Error(`Folder with ID ${id} not found`);\n      }\n      // Merge existing fields with updates\n      const folder = await workspaceCtx.yaak.folder.update({\n        ...existing,\n        ...updates,\n        id,\n      });\n      return {\n        content: [{ type: \"text\" as const, text: JSON.stringify(folder, null, 2) }],\n      };\n    },\n  );\n\n  server.registerTool(\n    \"delete_folder\",\n    {\n      title: \"Delete Folder\",\n      description: \"Delete a folder by ID\",\n      inputSchema: {\n        id: z.string().describe(\"Folder ID to delete\"),\n      },\n    },\n    async ({ id }) => {\n      const folder = await ctx.yaak.folder.delete({ id });\n      return {\n        content: [{ type: \"text\" as const, text: `Deleted: ${folder.name} (${folder.id})` }],\n      };\n    },\n  );\n}\n"
  },
  {
    "path": "plugins-external/mcp-server/src/tools/helpers.ts",
    "content": "import type { McpServerContext } from \"../types.js\";\n\nexport async function getWorkspaceContext(\n  ctx: McpServerContext,\n  workspaceId?: string,\n): Promise<McpServerContext> {\n  const workspaces = await ctx.yaak.workspace.list();\n\n  if (!workspaceId && workspaces.length > 1) {\n    const workspaceList = workspaces.map((w, i) => `${i + 1}. ${w.name} (ID: ${w.id})`).join(\"\\n\");\n    throw new Error(\n      `Multiple workspaces are open. Please specify which workspace to use.\\n\\n` +\n        `Currently open workspaces:\\n${workspaceList}\\n\\n` +\n        `You can use the list_workspaces tool to get workspace IDs, then use other tools ` +\n        `with the workspace context. For example, ask the user which workspace they want ` +\n        `to work with by presenting them with the numbered list above.`,\n    );\n  }\n\n  const workspace = workspaceId ? workspaces.find((w) => w.id === workspaceId) : workspaces[0];\n  if (!workspace) {\n    const workspaceList = workspaces.map((w) => `- ${w.name} (ID: ${w.id})`).join(\"\\n\");\n    throw new Error(\n      `Workspace with ID \"${workspaceId}\" not found.\\n\\n` +\n        `Available workspaces:\\n${workspaceList}`,\n    );\n  }\n\n  return {\n    yaak: ctx.yaak.workspace.withContext(workspace),\n  };\n}\n"
  },
  {
    "path": "plugins-external/mcp-server/src/tools/httpRequest.ts",
    "content": "import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport * as z from \"zod\";\nimport type { McpServerContext } from \"../types.js\";\nimport { getWorkspaceContext } from \"./helpers.js\";\nimport {\n  authenticationSchema,\n  authenticationTypeSchema,\n  bodySchema,\n  bodyTypeSchema,\n  headersSchema,\n  urlParametersSchema,\n  workspaceIdSchema,\n} from \"./schemas.js\";\n\nexport function registerHttpRequestTools(server: McpServer, ctx: McpServerContext) {\n  server.registerTool(\n    \"list_http_requests\",\n    {\n      title: \"List HTTP Requests\",\n      description: \"List all HTTP requests in a workspace\",\n      inputSchema: {\n        workspaceId: workspaceIdSchema,\n      },\n    },\n    async ({ workspaceId }) => {\n      const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);\n      const requests = await workspaceCtx.yaak.httpRequest.list();\n\n      return {\n        content: [\n          {\n            type: \"text\" as const,\n            text: JSON.stringify(requests, null, 2),\n          },\n        ],\n      };\n    },\n  );\n\n  server.registerTool(\n    \"get_http_request\",\n    {\n      title: \"Get HTTP Request\",\n      description: \"Get details of a specific HTTP request by ID\",\n      inputSchema: {\n        id: z.string().describe(\"The HTTP request ID\"),\n        workspaceId: workspaceIdSchema,\n      },\n    },\n    async ({ id, workspaceId }) => {\n      const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);\n      const request = await workspaceCtx.yaak.httpRequest.getById({ id });\n\n      return {\n        content: [\n          {\n            type: \"text\" as const,\n            text: JSON.stringify(request, null, 2),\n          },\n        ],\n      };\n    },\n  );\n\n  server.registerTool(\n    \"send_http_request\",\n    {\n      title: \"Send HTTP Request\",\n      description: \"Send an HTTP request and get the response\",\n      inputSchema: {\n        id: z.string().describe(\"The HTTP request ID to send\"),\n        environmentId: z.string().optional().describe(\"Optional environment ID to use\"),\n        workspaceId: workspaceIdSchema,\n      },\n    },\n    async ({ id, workspaceId }) => {\n      const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);\n      const httpRequest = await workspaceCtx.yaak.httpRequest.getById({ id });\n      if (httpRequest == null) {\n        throw new Error(`HTTP request with ID ${id} not found`);\n      }\n\n      const response = await workspaceCtx.yaak.httpRequest.send({ httpRequest });\n\n      return {\n        content: [\n          {\n            type: \"text\" as const,\n            text: JSON.stringify(response, null, 2),\n          },\n        ],\n      };\n    },\n  );\n\n  server.registerTool(\n    \"create_http_request\",\n    {\n      title: \"Create HTTP Request\",\n      description: \"Create a new HTTP request\",\n      inputSchema: {\n        workspaceId: workspaceIdSchema,\n        name: z\n          .string()\n          .optional()\n          .describe(\"Request name (empty string to auto-generate from URL)\"),\n        url: z.string().describe(\"Request URL\"),\n        method: z.string().optional().describe(\"HTTP method (defaults to GET)\"),\n        folderId: z.string().optional().describe(\"Parent folder ID\"),\n        description: z.string().optional().describe(\"Request description\"),\n        headers: headersSchema.describe(\"Request headers\"),\n        urlParameters: urlParametersSchema,\n        bodyType: bodyTypeSchema,\n        body: bodySchema,\n        authenticationType: authenticationTypeSchema,\n        authentication: authenticationSchema,\n      },\n    },\n    async ({ workspaceId: ogWorkspaceId, ...args }) => {\n      const workspaceCtx = await getWorkspaceContext(ctx, ogWorkspaceId);\n      const workspaceId = await workspaceCtx.yaak.window.workspaceId();\n      if (!workspaceId) {\n        throw new Error(\"No workspace is open\");\n      }\n\n      const httpRequest = await workspaceCtx.yaak.httpRequest.create({\n        workspaceId: workspaceId,\n        ...args,\n      });\n\n      return {\n        content: [{ type: \"text\" as const, text: JSON.stringify(httpRequest, null, 2) }],\n      };\n    },\n  );\n\n  server.registerTool(\n    \"update_http_request\",\n    {\n      title: \"Update HTTP Request\",\n      description: \"Update an existing HTTP request\",\n      inputSchema: {\n        id: z.string().describe(\"HTTP request ID to update\"),\n        workspaceId: workspaceIdSchema,\n        name: z.string().optional().describe(\"Request name\"),\n        url: z.string().optional().describe(\"Request URL\"),\n        method: z.string().optional().describe(\"HTTP method\"),\n        folderId: z.string().optional().describe(\"Parent folder ID\"),\n        description: z.string().optional().describe(\"Request description\"),\n        headers: headersSchema.describe(\"Request headers\"),\n        urlParameters: urlParametersSchema,\n        bodyType: bodyTypeSchema,\n        body: bodySchema,\n        authenticationType: authenticationTypeSchema,\n        authentication: authenticationSchema,\n      },\n    },\n    async ({ id, workspaceId, ...updates }) => {\n      const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);\n      // Fetch existing request to merge with updates\n      const existing = await workspaceCtx.yaak.httpRequest.getById({ id });\n      if (!existing) {\n        throw new Error(`HTTP request with ID ${id} not found`);\n      }\n      // Merge existing fields with updates\n      const httpRequest = await workspaceCtx.yaak.httpRequest.update({\n        ...existing,\n        ...updates,\n        id,\n      });\n      return {\n        content: [{ type: \"text\" as const, text: JSON.stringify(httpRequest, null, 2) }],\n      };\n    },\n  );\n\n  server.registerTool(\n    \"delete_http_request\",\n    {\n      title: \"Delete HTTP Request\",\n      description: \"Delete an HTTP request by ID\",\n      inputSchema: {\n        id: z.string().describe(\"HTTP request ID to delete\"),\n      },\n    },\n    async ({ id }) => {\n      const httpRequest = await ctx.yaak.httpRequest.delete({ id });\n      return {\n        content: [\n          { type: \"text\" as const, text: `Deleted: ${httpRequest.name} (${httpRequest.id})` },\n        ],\n      };\n    },\n  );\n}\n"
  },
  {
    "path": "plugins-external/mcp-server/src/tools/schemas.ts",
    "content": "import * as z from \"zod\";\n\nexport const workspaceIdSchema = z\n  .string()\n  .optional()\n  .describe(\"Workspace ID (required if multiple workspaces are open)\");\n\nexport const headersSchema = z\n  .array(\n    z.object({\n      name: z.string(),\n      value: z.string(),\n      enabled: z.boolean().default(true),\n    }),\n  )\n  .optional();\n\nexport const urlParametersSchema = z\n  .array(\n    z.object({\n      name: z.string(),\n      value: z.string(),\n      enabled: z.boolean().default(true),\n    }),\n  )\n  .optional()\n  .describe(\"URL query parameters\");\n\nexport const bodyTypeSchema = z\n  .string()\n  .optional()\n  .describe(\n    'Body type. Supported values: \"binary\", \"graphql\", \"application/x-www-form-urlencoded\", \"multipart/form-data\", or any text-based type (e.g., \"application/json\", \"text/plain\")',\n  );\n\nexport const bodySchema = z\n  .record(z.string(), z.any())\n  .optional()\n  .describe(\n    \"Body content object. Structure varies by bodyType:\\n\" +\n      '- \"binary\": { filePath: \"/path/to/file\" }\\n' +\n      '- \"graphql\": { query: \"{ users { name } }\", variables: \"{\\\\\"id\\\\\": \\\\\"123\\\\\"}\" }\\n' +\n      '- \"application/x-www-form-urlencoded\": { form: [{ name: \"key\", value: \"val\", enabled: true }] }\\n' +\n      '- \"multipart/form-data\": { form: [{ name: \"field\", value: \"text\", file: \"/path/to/file\", enabled: true }] }\\n' +\n      '- text-based (application/json, etc.): { text: \"raw body content\" }',\n  );\n\nexport const authenticationTypeSchema = z\n  .string()\n  .optional()\n  .describe(\n    'Authentication type. Common values: \"basic\", \"bearer\", \"oauth2\", \"apikey\", \"jwt\", \"awsv4\", \"oauth1\", \"ntlm\", \"none\". Use null to inherit from parent.',\n  );\n\nexport const authenticationSchema = z\n  .record(z.string(), z.any())\n  .optional()\n  .describe(\n    \"Authentication configuration object. Structure varies by authenticationType:\\n\" +\n      '- \"basic\": { username: \"user\", password: \"pass\" }\\n' +\n      '- \"bearer\": { token: \"abc123\", prefix: \"Bearer\" }\\n' +\n      '- \"oauth2\": { clientId: \"...\", clientSecret: \"...\", grantType: \"authorization_code\", authorizationUrl: \"...\", accessTokenUrl: \"...\", scope: \"...\", ... }\\n' +\n      '- \"apikey\": { location: \"header\" | \"query\", key: \"X-API-Key\", value: \"...\" }\\n' +\n      '- \"jwt\": { algorithm: \"HS256\", secret: \"...\", payload: \"{ ... }\" }\\n' +\n      '- \"awsv4\": { accessKeyId: \"...\", secretAccessKey: \"...\", service: \"sts\", region: \"us-east-1\", sessionToken: \"...\" }\\n' +\n      '- \"none\": {}',\n  );\n"
  },
  {
    "path": "plugins-external/mcp-server/src/tools/toast.ts",
    "content": "import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport type { Color, Icon } from \"@yaakapp/api\";\nimport * as z from \"zod\";\nimport type { McpServerContext } from \"../types.js\";\n\nconst ICON_VALUES = [\n  \"alert_triangle\",\n  \"check\",\n  \"check_circle\",\n  \"chevron_down\",\n  \"copy\",\n  \"info\",\n  \"pin\",\n  \"search\",\n  \"trash\",\n] as const satisfies readonly Icon[];\n\nconst COLOR_VALUES = [\n  \"primary\",\n  \"secondary\",\n  \"info\",\n  \"success\",\n  \"notice\",\n  \"warning\",\n  \"danger\",\n] as const satisfies readonly Color[];\n\nexport function registerToastTools(server: McpServer, ctx: McpServerContext) {\n  server.registerTool(\n    \"show_toast\",\n    {\n      title: \"Show Toast\",\n      description: \"Show a toast notification in Yaak\",\n      inputSchema: {\n        message: z.string().describe(\"The message to display\"),\n        icon: z.enum(ICON_VALUES).optional().describe(\"Icon name\"),\n        color: z.enum(COLOR_VALUES).optional().describe(\"Toast color\"),\n        timeout: z.number().optional().describe(\"Timeout in milliseconds\"),\n      },\n    },\n    async ({ message, icon, color, timeout }) => {\n      await ctx.yaak.toast.show({\n        message,\n        icon,\n        color,\n        timeout,\n      });\n\n      return {\n        content: [\n          {\n            type: \"text\" as const,\n            text: `✓ Toast shown: \"${message}\"`,\n          },\n        ],\n      };\n    },\n  );\n}\n"
  },
  {
    "path": "plugins-external/mcp-server/src/tools/window.ts",
    "content": "import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport type { McpServerContext } from \"../types.js\";\nimport { getWorkspaceContext } from \"./helpers.js\";\n\nexport function registerWindowTools(server: McpServer, ctx: McpServerContext) {\n  server.registerTool(\n    \"get_workspace_id\",\n    {\n      title: \"Get Workspace ID\",\n      description: \"Get the current workspace ID\",\n    },\n    async () => {\n      const workspaceCtx = await getWorkspaceContext(ctx);\n      const workspaceId = await workspaceCtx.yaak.window.workspaceId();\n\n      return {\n        content: [\n          {\n            type: \"text\" as const,\n            text: workspaceId || \"No workspace open\",\n          },\n        ],\n      };\n    },\n  );\n\n  server.registerTool(\n    \"get_environment_id\",\n    {\n      title: \"Get Environment ID\",\n      description: \"Get the current environment ID\",\n    },\n    async () => {\n      const workspaceCtx = await getWorkspaceContext(ctx);\n      const environmentId = await workspaceCtx.yaak.window.environmentId();\n\n      return {\n        content: [\n          {\n            type: \"text\" as const,\n            text: environmentId || \"No environment selected\",\n          },\n        ],\n      };\n    },\n  );\n}\n"
  },
  {
    "path": "plugins-external/mcp-server/src/tools/workspace.ts",
    "content": "import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport type { McpServerContext } from \"../types.js\";\n\nexport function registerWorkspaceTools(server: McpServer, ctx: McpServerContext) {\n  server.registerTool(\n    \"list_workspaces\",\n    {\n      title: \"List Workspaces\",\n      description: \"List all open workspaces in Yaak\",\n    },\n    async () => {\n      const workspaces = await ctx.yaak.workspace.list();\n\n      return {\n        content: [\n          {\n            type: \"text\" as const,\n            text: JSON.stringify(workspaces, null, 2),\n          },\n        ],\n      };\n    },\n  );\n}\n"
  },
  {
    "path": "plugins-external/mcp-server/src/types.ts",
    "content": "import type { Context } from \"@yaakapp/api\";\n\nexport interface McpServerContext {\n  yaak: Context;\n}\n"
  },
  {
    "path": "plugins-external/mcp-server/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"skipLibCheck\": true,\n    \"moduleResolution\": \"Bundler\"\n  }\n}\n"
  },
  {
    "path": "rustfmt.toml",
    "content": "edition = \"2024\"\n\n# Widths\nchain_width = 100\nmax_width = 100\nsingle_line_if_else_max_width = 100\nfn_call_width = 100\nstruct_lit_width = 100\n"
  },
  {
    "path": "scripts/.gitignore",
    "content": "tmp-*\n"
  },
  {
    "path": "scripts/create-migration.cjs",
    "content": "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst readline = require(\"readline\");\nconst slugify = require(\"slugify\");\n\nconst rl = readline.createInterface({\n  input: process.stdin,\n  output: process.stdout,\n});\n\nfunction generateTimestamp() {\n  const now = new Date();\n  const year = now.getFullYear();\n  const month = String(now.getMonth() + 1).padStart(2, \"0\");\n  const day = String(now.getDate()).padStart(2, \"0\");\n  const hours = String(now.getHours()).padStart(2, \"0\");\n  const minutes = String(now.getMinutes()).padStart(2, \"0\");\n  const seconds = String(now.getSeconds()).padStart(2, \"0\");\n  return `${year}${month}${day}${hours}${minutes}${seconds}`;\n}\n\nasync function createMigration() {\n  try {\n    const migrationName = await new Promise((resolve) => {\n      rl.question(\"Enter migration name: \", resolve);\n    });\n\n    const timestamp = generateTimestamp();\n    const fileName = `${timestamp}_${slugify(String(migrationName), { lower: true })}.sql`;\n    const migrationsDir = path.join(__dirname, \"../crates/yaak-models/migrations\");\n    const filePath = path.join(migrationsDir, fileName);\n\n    if (!fs.existsSync(migrationsDir)) {\n      fs.mkdirSync(migrationsDir, { recursive: true });\n    }\n\n    fs.writeFileSync(filePath, \"-- Add migration SQL here\\n\");\n    console.log(`Created migration file: ${fileName}`);\n  } catch (error) {\n    console.error(\"Error creating migration:\", error);\n  } finally {\n    rl.close();\n  }\n}\n\ncreateMigration().catch(console.error);\n"
  },
  {
    "path": "scripts/git-hooks/post-checkout.mjs",
    "content": "#!/usr/bin/env node\n\n/**\n * Git post-checkout hook for auto-configuring worktree environments.\n * This runs after 'git checkout' or 'git worktree add'.\n *\n * Args from git:\n *   process.argv[2] - previous HEAD ref\n *   process.argv[3] - new HEAD ref\n *   process.argv[4] - flag (1 = branch checkout, 0 = file checkout)\n */\n\nimport fs from \"fs\";\nimport path from \"path\";\nimport { execSync, execFileSync } from \"child_process\";\nimport { fileURLToPath } from \"url\";\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\nconst isBranchCheckout = process.argv[4] === \"1\";\n\nif (!isBranchCheckout) {\n  process.exit(0);\n}\n\n// Check if we're in a worktree by looking for .git file (not directory)\nconst gitPath = path.join(process.cwd(), \".git\");\nconst isWorktree = fs.existsSync(gitPath) && fs.statSync(gitPath).isFile();\n\nif (!isWorktree) {\n  process.exit(0);\n}\n\nconst envLocalPath = path.join(process.cwd(), \".env.local\");\n\n// Don't overwrite existing .env.local\nif (fs.existsSync(envLocalPath)) {\n  process.exit(0);\n}\n\nconsole.log(\"Detected new worktree - configuring ports in .env.local\");\n\n// Find the highest ports in use across all worktrees\n// Main worktree (first in list) is assumed to use default ports 1420/64343\nlet maxMcpPort = 64343;\nlet maxDevPort = 1420;\n\ntry {\n  const worktreeList = execSync(\"git worktree list --porcelain\", { encoding: \"utf8\" });\n  const worktreePaths = worktreeList\n    .split(\"\\n\")\n    .filter((line) => line.startsWith(\"worktree \"))\n    .map((line) => line.replace(\"worktree \", \"\").trim());\n\n  // Skip the first worktree (main) since it uses default ports\n  for (let i = 1; i < worktreePaths.length; i++) {\n    const worktreePath = worktreePaths[i];\n    const envPath = path.join(worktreePath, \".env.local\");\n\n    if (fs.existsSync(envPath)) {\n      const content = fs.readFileSync(envPath, \"utf8\");\n\n      const mcpMatch = content.match(/^YAAK_PLUGIN_MCP_SERVER_PORT=(\\d+)/m);\n      if (mcpMatch) {\n        const port = parseInt(mcpMatch[1], 10);\n        if (port > maxMcpPort) {\n          maxMcpPort = port;\n        }\n      }\n\n      const devMatch = content.match(/^YAAK_DEV_PORT=(\\d+)/m);\n      if (devMatch) {\n        const port = parseInt(devMatch[1], 10);\n        if (port > maxDevPort) {\n          maxDevPort = port;\n        }\n      }\n    }\n  }\n\n  // Increment to get the next available port\n  maxDevPort++;\n  maxMcpPort++;\n} catch (err) {\n  console.error(\"Warning: Could not check other worktrees for port conflicts:\", err.message);\n  // Continue with default ports\n}\n\n// Get worktree name from current directory\nconst worktreeName = path.basename(process.cwd());\n\n// Create .env.local with unique ports\nconst envContent = `# Auto-generated by git post-checkout hook\n# This file configures ports for this worktree to avoid conflicts\n\n# Vite dev server port (main worktree uses 1420)\nYAAK_DEV_PORT=${maxDevPort}\n\n# MCP Server port (main worktree uses 64343)\nYAAK_PLUGIN_MCP_SERVER_PORT=${maxMcpPort}\n`;\n\nfs.writeFileSync(envLocalPath, envContent, \"utf8\");\nconsole.log(\n  `Created .env.local with YAAK_DEV_PORT=${maxDevPort} and YAAK_PLUGIN_MCP_SERVER_PORT=${maxMcpPort}`,\n);\n\n// Create tauri.worktree.conf.json with unique app identifier for complete isolation\n// This gives each worktree its own app data directory, avoiding the need for DB path prefixes\nconst tauriWorktreeConfig = {\n  identifier: `app.yaak.desktop.dev.${worktreeName}`,\n  productName: `Daak (${worktreeName})`,\n};\n\nconst tauriConfigPath = path.join(\n  process.cwd(),\n  \"crates-tauri\",\n  \"yaak-app\",\n  \"tauri.worktree.conf.json\",\n);\nfs.writeFileSync(tauriConfigPath, JSON.stringify(tauriWorktreeConfig, null, 2) + \"\\n\", \"utf8\");\nconsole.log(`Created tauri.worktree.conf.json with identifier: ${tauriWorktreeConfig.identifier}`);\n\n// Copy gitignored editor config folders from main worktree (.zed, .vscode, .claude, etc.)\n// This ensures your editor settings, tasks, and configurations are available in the new worktree\n// without needing to manually copy them or commit them to git.\ntry {\n  const worktreeList = execSync(\"git worktree list --porcelain\", { encoding: \"utf8\" });\n  const mainWorktreePath = worktreeList\n    .split(\"\\n\")\n    .find((line) => line.startsWith(\"worktree \"))\n    ?.replace(\"worktree \", \"\")\n    .trim();\n\n  if (mainWorktreePath) {\n    // Find all .* folders in main worktree root that are gitignored\n    const entries = fs.readdirSync(mainWorktreePath, { withFileTypes: true });\n    const dotFolders = entries\n      .filter((entry) => entry.isDirectory() && entry.name.startsWith(\".\"))\n      .map((entry) => entry.name);\n\n    for (const folder of dotFolders) {\n      const sourcePath = path.join(mainWorktreePath, folder);\n      const destPath = path.join(process.cwd(), folder);\n\n      try {\n        // Check if it's gitignored - run from main worktree directory\n        execFileSync(\"git\", [\"check-ignore\", \"-q\", folder], {\n          stdio: \"pipe\",\n          cwd: mainWorktreePath,\n        });\n\n        // It's gitignored, copy it\n        fs.cpSync(sourcePath, destPath, { recursive: true });\n        console.log(`Copied ${folder} from main worktree`);\n      } catch {\n        // Not gitignored or doesn't exist, skip\n      }\n    }\n  }\n} catch (err) {\n  console.warn(\"Warning: Could not copy files from main worktree:\", err.message);\n  // Continue anyway\n}\n\nconsole.log(\"\\n✓ Worktree setup complete! Run `npm run init` to install dependencies.\");\n"
  },
  {
    "path": "scripts/install-wasm-pack.cjs",
    "content": "const { execSync } = require(\"node:child_process\");\n\nconst version = tryExecSync(\"wasm-pack --version\");\nif (version.startsWith(\"wasm-pack \")) {\n  console.log(\"wasm-pack already installed\");\n  return;\n}\n\nconsole.log(\"Installing wasm-pack via cargo...\");\nexecSync(\"cargo install wasm-pack --locked\", { stdio: \"inherit\" });\n\nfunction tryExecSync(cmd) {\n  try {\n    return execSync(cmd, { stdio: \"pipe\" }).toString(\"utf-8\");\n  } catch {\n    return \"\";\n  }\n}\n"
  },
  {
    "path": "scripts/publish-core-plugins.cjs",
    "content": "const { readdirSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst { execSync } = require(\"node:child_process\");\n\nconst pluginsDir = path.join(__dirname, \"..\", \"plugins\");\n\nconsole.log(\"Publishing core Yaak plugins\");\n\nfor (const name of readdirSync(pluginsDir)) {\n  const dir = path.join(pluginsDir, name);\n  if (name.startsWith(\".\")) continue;\n  console.log(\"Building plugin\", dir);\n  execSync(\"npm run build\", { stdio: \"inherit\", cwd: dir });\n  execSync(\"yaakcli publish\", {\n    stdio: \"inherit\",\n    cwd: dir,\n    env: { ...process.env, ENVIRONMENT: \"development\" },\n  });\n}\n"
  },
  {
    "path": "scripts/replace-version.cjs",
    "content": "const path = require(\"path\");\nconst fs = require(\"fs\");\n\nconst version = process.env.YAAK_VERSION?.replace(\"v\", \"\");\nif (!version) {\n  throw new Error(\"YAAK_VERSION environment variable not set\");\n}\n\nconst tauriConfigPath = path.join(__dirname, \"../crates-tauri/yaak-app/tauri.conf.json\");\nconst tauriConfig = JSON.parse(fs.readFileSync(tauriConfigPath, \"utf8\"));\n\ntauriConfig.version = version;\n\nconsole.log(\"Writing version \" + version + \" to \" + tauriConfigPath);\nfs.writeFileSync(tauriConfigPath, JSON.stringify(tauriConfig, null, 2));\n"
  },
  {
    "path": "scripts/run-dev.mjs",
    "content": "#!/usr/bin/env node\n\n/**\n * Script to run Tauri dev server with dynamic port configuration.\n * Loads port from .env.local if present, otherwise uses default port 1420.\n */\n\nimport { spawnSync } from \"child_process\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport { fileURLToPath } from \"url\";\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\nconst rootDir = path.join(__dirname, \"..\");\n\n// Load .env.local if it exists\nconst envLocalPath = path.join(rootDir, \".env.local\");\nif (fs.existsSync(envLocalPath)) {\n  const envContent = fs.readFileSync(envLocalPath, \"utf8\");\n  const envVars = envContent\n    .split(\"\\n\")\n    .filter((line) => line && !line.startsWith(\"#\"))\n    .reduce((acc, line) => {\n      const [key, value] = line.split(\"=\");\n      if (key && value) {\n        acc[key.trim()] = value.trim();\n      }\n      return acc;\n    }, {});\n\n  Object.assign(process.env, envVars);\n}\n\nconst port = process.env.YAAK_DEV_PORT || \"1420\";\nconst config = JSON.stringify({ build: { devUrl: `http://localhost:${port}` } });\n\n// Get additional arguments passed after npm run app-dev --\nconst additionalArgs = process.argv.slice(2);\n\nconst args = [\n  \"dev\",\n  \"--no-watch\",\n  \"--config\",\n  \"crates-tauri/yaak-app/tauri.development.conf.json\",\n  \"--config\",\n  config,\n  ...additionalArgs,\n];\n\n// Invoke the tauri CLI JS entry point directly via node to avoid shell escaping issues on Windows\nconst tauriJs = path.join(rootDir, \"node_modules\", \"@tauri-apps\", \"cli\", \"tauri.js\");\n\nconst result = spawnSync(process.execPath, [tauriJs, ...args], {\n  stdio: \"inherit\",\n  env: process.env,\n});\n\nprocess.exit(result.status || 0);\n"
  },
  {
    "path": "scripts/run-workspaces-dev.mjs",
    "content": "#!/usr/bin/env node\n\n/**\n * Runs `npm run dev` in parallel for all workspaces that have a dev script.\n * Handles cleanup of child processes on exit.\n */\n\nimport { spawn } from \"child_process\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport { fileURLToPath } from \"url\";\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\nconst rootDir = path.join(__dirname, \"..\");\n\n// Read root package.json to get workspaces\nconst rootPkg = JSON.parse(fs.readFileSync(path.join(rootDir, \"package.json\"), \"utf8\"));\nconst workspaces = rootPkg.workspaces || [];\n\n// Find all workspaces with a dev script\nconst workspacesWithDev = workspaces.filter((ws) => {\n  const pkgPath = path.join(rootDir, ws, \"package.json\");\n  if (!fs.existsSync(pkgPath)) return false;\n  const pkg = JSON.parse(fs.readFileSync(pkgPath, \"utf8\"));\n  return pkg.scripts?.dev != null;\n});\n\nif (workspacesWithDev.length === 0) {\n  console.log(\"No workspaces with dev script found\");\n  process.exit(0);\n}\n\nconsole.log(`Starting dev for ${workspacesWithDev.length} workspaces...`);\n\nconst children = [];\n\n// Spawn all dev processes\nfor (const ws of workspacesWithDev) {\n  const cwd = path.join(rootDir, ws);\n  const child = spawn(\"npm\", [\"run\", \"dev\"], {\n    cwd,\n    stdio: \"inherit\",\n    shell: process.platform === \"win32\",\n  });\n\n  child.on(\"error\", (err) => {\n    console.error(`Error in ${ws}:`, err.message);\n  });\n\n  children.push({ ws, child });\n}\n\n// Cleanup function to kill all children\nfunction cleanup() {\n  for (const { child } of children) {\n    if (child.exitCode === null) {\n      // Process still running\n      if (process.platform === \"win32\") {\n        spawn(\"taskkill\", [\"/pid\", child.pid, \"/f\", \"/t\"], { shell: true });\n      } else {\n        child.kill(\"SIGTERM\");\n      }\n    }\n  }\n}\n\n// Handle various exit signals\nprocess.on(\"SIGINT\", () => {\n  cleanup();\n  process.exit(0);\n});\n\nprocess.on(\"SIGTERM\", () => {\n  cleanup();\n  process.exit(0);\n});\n\nprocess.on(\"exit\", cleanup);\n\n// Keep the process running\nprocess.stdin.resume();\n"
  },
  {
    "path": "scripts/vendor-node.cjs",
    "content": "const path = require(\"node:path\");\nconst crypto = require(\"node:crypto\");\nconst fs = require(\"node:fs\");\nconst decompress = require(\"decompress\");\nconst Downloader = require(\"nodejs-file-downloader\");\nconst { rmSync, cpSync, mkdirSync, existsSync } = require(\"node:fs\");\nconst { execSync } = require(\"node:child_process\");\n\nconst NODE_VERSION = \"v24.11.1\";\n\n// `${process.platform}_${process.arch}`\nconst MAC_ARM = \"darwin_arm64\";\nconst MAC_X64 = \"darwin_x64\";\nconst LNX_ARM = \"linux_arm64\";\nconst LNX_X64 = \"linux_x64\";\nconst WIN_X64 = \"win32_x64\";\nconst WIN_ARM = \"win32_arm64\";\n\nconst URL_MAP = {\n  [MAC_ARM]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-darwin-arm64.tar.gz`,\n  [MAC_X64]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-darwin-x64.tar.gz`,\n  [LNX_ARM]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-linux-arm64.tar.gz`,\n  [LNX_X64]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.gz`,\n  [WIN_X64]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-win-x64.zip`,\n  [WIN_ARM]: `https://nodejs.org/download/release/${NODE_VERSION}/node-${NODE_VERSION}-win-arm64.zip`,\n};\n\nconst SRC_BIN_MAP = {\n  [MAC_ARM]: `node-${NODE_VERSION}-darwin-arm64/bin/node`,\n  [MAC_X64]: `node-${NODE_VERSION}-darwin-x64/bin/node`,\n  [LNX_ARM]: `node-${NODE_VERSION}-linux-arm64/bin/node`,\n  [LNX_X64]: `node-${NODE_VERSION}-linux-x64/bin/node`,\n  [WIN_X64]: `node-${NODE_VERSION}-win-x64/node.exe`,\n  [WIN_ARM]: `node-${NODE_VERSION}-win-arm64/node.exe`,\n};\n\nconst DST_BIN_MAP = {\n  [MAC_ARM]: \"yaaknode\",\n  [MAC_X64]: \"yaaknode\",\n  [LNX_ARM]: \"yaaknode\",\n  [LNX_X64]: \"yaaknode\",\n  [WIN_X64]: \"yaaknode.exe\",\n  [WIN_ARM]: \"yaaknode.exe\",\n};\n\nconst SHA256_MAP = {\n  [MAC_ARM]: \"b05aa3a66efe680023f930bd5af3fdbbd542794da5644ca2ad711d68cbd4dc35\",\n  [MAC_X64]: \"096081b6d6fcdd3f5ba0f5f1d44a47e83037ad2e78eada26671c252fe64dd111\",\n  [LNX_ARM]: \"0dc93ec5c798b0d347f068db6d205d03dea9a71765e6a53922b682b91265d71f\",\n  [LNX_X64]: \"58a5ff5cc8f2200e458bea22e329d5c1994aa1b111d499ca46ec2411d58239ca\",\n  [WIN_X64]: \"5355ae6d7c49eddcfde7d34ac3486820600a831bf81dc3bdca5c8db6a9bb0e76\",\n  [WIN_ARM]: \"ce9ee4e547ebdff355beb48e309b166c24df6be0291c9eaf103ce15f3de9e5b4\",\n};\n\nconst key = `${process.platform}_${process.env.YAAK_TARGET_ARCH ?? process.arch}`;\n\nconst destDir = path.join(__dirname, `..`, \"crates-tauri\", \"yaak-app\", \"vendored\", \"node\");\nconst binDest = path.join(destDir, DST_BIN_MAP[key]);\nconsole.log(`Vendoring NodeJS ${NODE_VERSION} for ${key}`);\n\nif (existsSync(binDest) && tryExecSync(`${binDest} --version`).trim() === NODE_VERSION) {\n  console.log(\"NodeJS already vendored\");\n  return;\n}\n\nrmSync(destDir, { recursive: true, force: true });\nmkdirSync(destDir, { recursive: true });\n\nconst url = URL_MAP[key];\nconst tmpDir = path.join(__dirname, \"tmp-node\");\nrmSync(tmpDir, { recursive: true, force: true });\n\n(async function () {\n  // Download GitHub release artifact\n  console.log(\"Downloading NodeJS at\", url);\n  const { filePath } = await new Downloader({\n    url,\n    directory: tmpDir,\n    timeout: 1000 * 60 * 2,\n  }).download();\n\n  // Verify SHA256\n  const expectedHash = SHA256_MAP[key];\n  const fileBuffer = fs.readFileSync(filePath);\n  const actualHash = crypto.createHash(\"sha256\").update(fileBuffer).digest(\"hex\");\n  if (actualHash !== expectedHash) {\n    throw new Error(\n      `SHA256 mismatch for ${path.basename(filePath)}\\n  expected: ${expectedHash}\\n  actual:   ${actualHash}`,\n    );\n  }\n  console.log(\"SHA256 verified:\", actualHash);\n\n  // Decompress to the same directory\n  await decompress(filePath, tmpDir, {});\n\n  // Copy binary\n  const binSrc = path.join(tmpDir, SRC_BIN_MAP[key]);\n  cpSync(binSrc, binDest);\n  rmSync(tmpDir, { recursive: true, force: true });\n\n  console.log(\"Downloaded NodeJS to\", binDest);\n})().catch((err) => {\n  console.log(\"Script failed:\", err);\n  process.exit(1);\n});\n\nfunction tryExecSync(cmd) {\n  try {\n    return execSync(cmd, { stdio: \"pipe\" }).toString(\"utf-8\");\n  } catch {\n    return \"\";\n  }\n}\n"
  },
  {
    "path": "scripts/vendor-plugins.cjs",
    "content": "const { readdirSync, cpSync, existsSync, mkdirSync } = require(\"node:fs\");\nconst path = require(\"node:path\");\n\nconst pluginsDir = path.join(__dirname, \"..\", \"plugins\");\nconst externalPluginsDir = path.join(__dirname, \"..\", \"plugins-external\");\n\n// Get list of external (non-bundled) plugins\nconst externalPlugins = new Set();\nif (existsSync(externalPluginsDir)) {\n  for (const name of readdirSync(externalPluginsDir)) {\n    if (!name.startsWith(\".\")) {\n      externalPlugins.add(name);\n    }\n  }\n}\n\nconsole.log(\"Copying Yaak plugins to\", pluginsDir);\n\nfor (const name of readdirSync(pluginsDir)) {\n  const dir = path.join(pluginsDir, name);\n  if (name.startsWith(\".\")) continue;\n  if (externalPlugins.has(name)) {\n    console.log(`Skipping ${name} (external plugin)`);\n    continue;\n  }\n  const destDir = path.join(__dirname, \"../crates-tauri/yaak-app/vendored/plugins/\", name);\n  mkdirSync(destDir, { recursive: true });\n  console.log(`Copying ${name} to ${destDir}`);\n  cpSync(path.join(dir, \"package.json\"), path.join(destDir, \"package.json\"));\n  cpSync(path.join(dir, \"build\"), path.join(destDir, \"build\"), { recursive: true });\n}\n"
  },
  {
    "path": "scripts/vendor-protoc.cjs",
    "content": "const crypto = require(\"node:crypto\");\nconst fs = require(\"node:fs\");\nconst decompress = require(\"decompress\");\nconst Downloader = require(\"nodejs-file-downloader\");\nconst path = require(\"node:path\");\nconst { rmSync, mkdirSync, cpSync, existsSync, statSync, chmodSync } = require(\"node:fs\");\nconst { execSync } = require(\"node:child_process\");\n\nconst VERSION = \"33.1\";\n\n// `${process.platform}_${process.arch}`\nconst MAC_ARM = \"darwin_arm64\";\nconst MAC_X64 = \"darwin_x64\";\nconst LNX_ARM = \"linux_arm64\";\nconst LNX_X64 = \"linux_x64\";\nconst WIN_X64 = \"win32_x64\";\nconst WIN_ARM = \"win32_arm64\";\n\nconst URL_MAP = {\n  [MAC_ARM]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-osx-aarch_64.zip`,\n  [MAC_X64]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-osx-x86_64.zip`,\n  [LNX_ARM]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-linux-aarch_64.zip`,\n  [LNX_X64]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-linux-x86_64.zip`,\n  [WIN_X64]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-win64.zip`,\n  [WIN_ARM]: `https://github.com/protocolbuffers/protobuf/releases/download/v${VERSION}/protoc-${VERSION}-win64.zip`,\n};\n\nconst SRC_BIN_MAP = {\n  [MAC_ARM]: \"bin/protoc\",\n  [MAC_X64]: \"bin/protoc\",\n  [LNX_ARM]: \"bin/protoc\",\n  [LNX_X64]: \"bin/protoc\",\n  [WIN_X64]: \"bin/protoc.exe\",\n  [WIN_ARM]: \"bin/protoc.exe\",\n};\n\nconst DST_BIN_MAP = {\n  [MAC_ARM]: \"yaakprotoc\",\n  [MAC_X64]: \"yaakprotoc\",\n  [LNX_ARM]: \"yaakprotoc\",\n  [LNX_X64]: \"yaakprotoc\",\n  [WIN_X64]: \"yaakprotoc.exe\",\n  [WIN_ARM]: \"yaakprotoc.exe\",\n};\n\nconst SHA256_MAP = {\n  [MAC_ARM]: \"db7e66ff7f9080614d0f5505a6b0ac488cf89a15621b6a361672d1332ec2e14e\",\n  [MAC_X64]: \"e20b5f930e886da85e7402776a4959efb1ed60c57e72794bcade765e67abaa82\",\n  [LNX_ARM]: \"6018147740548e0e0f764408c87f4cd040e6e1c1203e13aeacaf811892b604f3\",\n  [LNX_X64]: \"f3340e28a83d1c637d8bafdeed92b9f7db6a384c26bca880a6e5217b40a4328b\",\n  [WIN_X64]: \"d7a207fb6eec0e4b1b6613be3b7d11905375b6fd1147a071116eb8e9f24ac53b\",\n  [WIN_ARM]: \"d7a207fb6eec0e4b1b6613be3b7d11905375b6fd1147a071116eb8e9f24ac53b\",\n};\n\nconst dstDir = path.join(__dirname, `..`, \"crates-tauri\", \"yaak-app\", \"vendored\", \"protoc\");\nconst key = `${process.platform}_${process.env.YAAK_TARGET_ARCH ?? process.arch}`;\nconsole.log(`Vendoring protoc ${VERSION} for ${key}`);\n\nconst url = URL_MAP[key];\nconst tmpDir = path.join(__dirname, \"tmp-protoc\");\nconst binSrc = path.join(tmpDir, SRC_BIN_MAP[key]);\nconst binDst = path.join(dstDir, DST_BIN_MAP[key]);\n\nif (existsSync(binDst) && tryExecSync(`${binDst} --version`).trim().includes(VERSION)) {\n  console.log(\"Protoc already vendored\");\n  return;\n}\n\nrmSync(tmpDir, { recursive: true, force: true });\nrmSync(dstDir, { recursive: true, force: true });\nmkdirSync(dstDir, { recursive: true });\n\n(async function () {\n  // Download GitHub release artifact\n  const { filePath } = await new Downloader({ url, directory: tmpDir }).download();\n\n  // Verify SHA256\n  const expectedHash = SHA256_MAP[key];\n  const fileBuffer = fs.readFileSync(filePath);\n  const actualHash = crypto.createHash(\"sha256\").update(fileBuffer).digest(\"hex\");\n  if (actualHash !== expectedHash) {\n    throw new Error(\n      `SHA256 mismatch for ${path.basename(filePath)}\\n  expected: ${expectedHash}\\n  actual:   ${actualHash}`,\n    );\n  }\n  console.log(\"SHA256 verified:\", actualHash);\n\n  // Decompress to the same directory\n  await decompress(filePath, tmpDir, {});\n\n  // Copy binary\n  cpSync(binSrc, binDst);\n\n  // Copy other files\n  const includeSrc = path.join(tmpDir, \"include\");\n  const includeDst = path.join(dstDir, \"include\");\n  cpSync(includeSrc, includeDst, { recursive: true });\n  rmSync(tmpDir, { recursive: true, force: true });\n\n  // Make binary writable, so we can sign it during release\n  const stat = statSync(binDst);\n  const newMode = stat.mode | 0o200;\n  chmodSync(binDst, newMode);\n\n  console.log(\"Downloaded protoc to\", binDst);\n})().catch((err) => console.log(\"Script failed:\", err));\n\nfunction tryExecSync(cmd) {\n  try {\n    return execSync(cmd, { stdio: \"pipe\" }).toString(\"utf-8\");\n  } catch {\n    return \"\";\n  }\n}\n"
  },
  {
    "path": "src-web/.gitignore",
    "content": "vite.config.d.ts\nvite.config.js\n"
  },
  {
    "path": "src-web/commands/commands.tsx",
    "content": "import { createWorkspaceModel, type Folder, modelTypeLabel } from \"@yaakapp-internal/models\";\nimport { applySync, calculateSync } from \"@yaakapp-internal/sync\";\nimport { Banner } from \"../components/core/Banner\";\nimport { Button } from \"../components/core/Button\";\nimport { InlineCode } from \"../components/core/InlineCode\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeaderCell,\n  TableRow,\n  TruncatedWideTableCell,\n} from \"../components/core/Table\";\nimport { activeWorkspaceIdAtom } from \"../hooks/useActiveWorkspace\";\nimport { createFastMutation } from \"../hooks/useFastMutation\";\nimport { showDialog } from \"../lib/dialog\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { pluralizeCount } from \"../lib/pluralize\";\nimport { showPrompt } from \"../lib/prompt\";\nimport { resolvedModelNameWithFolders } from \"../lib/resolvedModelName\";\n\nexport const createFolder = createFastMutation<\n  string | null,\n  void,\n  Partial<Pick<Folder, \"name\" | \"sortPriority\" | \"folderId\">>\n>({\n  mutationKey: [\"create_folder\"],\n  mutationFn: async (patch) => {\n    const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n    if (workspaceId == null) {\n      throw new Error(\"Cannot create folder when there's no active workspace\");\n    }\n\n    if (!patch.name) {\n      const name = await showPrompt({\n        id: \"new-folder\",\n        label: \"Name\",\n        defaultValue: \"Folder\",\n        title: \"New Folder\",\n        confirmText: \"Create\",\n        placeholder: \"Name\",\n      });\n      if (name == null) return null;\n\n      patch.name = name;\n    }\n\n    patch.sortPriority = patch.sortPriority || -Date.now();\n    const id = await createWorkspaceModel({ model: \"folder\", workspaceId, ...patch });\n    return id;\n  },\n});\n\nexport const syncWorkspace = createFastMutation<\n  void,\n  void,\n  { workspaceId: string; syncDir: string; force?: boolean }\n>({\n  mutationKey: [],\n  mutationFn: async ({ workspaceId, syncDir, force }) => {\n    const ops = (await calculateSync(workspaceId, syncDir)) ?? [];\n    if (ops.length === 0) {\n      console.log(\"Nothing to sync\", workspaceId, syncDir);\n      return;\n    }\n    console.log(\"Syncing workspace\", workspaceId, syncDir, ops);\n\n    const dbOps = ops.filter((o) => o.type.startsWith(\"db\"));\n\n    if (dbOps.length === 0) {\n      await applySync(workspaceId, syncDir, ops);\n      return;\n    }\n\n    const isDeletingWorkspace = ops.some(\n      (o) => o.type === \"dbDelete\" && o.model.model === \"workspace\",\n    );\n\n    console.log(\"Directory changes detected\", { dbOps, ops });\n\n    if (force) {\n      await applySync(workspaceId, syncDir, ops);\n      return;\n    }\n\n    showDialog({\n      id: \"commit-sync\",\n      title: \"Changes Detected\",\n      size: \"md\",\n      render: ({ hide }) => (\n        <form\n          className=\"h-full grid grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3\"\n          onSubmit={async (e) => {\n            e.preventDefault();\n            await applySync(workspaceId, syncDir, ops);\n            hide();\n          }}\n        >\n          {isDeletingWorkspace ? (\n            <Banner color=\"danger\">\n              🚨 <strong>Changes contain a workspace deletion!</strong>\n            </Banner>\n          ) : (\n            <span />\n          )}\n          <p>\n            {pluralizeCount(\"file\", dbOps.length)} in the directory{\" \"}\n            {dbOps.length === 1 ? \"has\" : \"have\"} changed. Do you want to update your workspace?\n          </p>\n          <Table scrollable className=\"my-4\">\n            <TableHead>\n              <TableRow>\n                <TableHeaderCell>Type</TableHeaderCell>\n                <TableHeaderCell>Name</TableHeaderCell>\n                <TableHeaderCell>Operation</TableHeaderCell>\n              </TableRow>\n            </TableHead>\n            <TableBody>\n              {dbOps.map((op, i) => {\n                let name: string;\n                let label: string;\n                let color: string;\n                let model: string;\n\n                if (op.type === \"dbCreate\") {\n                  label = \"create\";\n                  name = resolvedModelNameWithFolders(op.fs.model);\n                  color = \"text-success\";\n                  model = modelTypeLabel(op.fs.model);\n                } else if (op.type === \"dbUpdate\") {\n                  label = \"update\";\n                  name = resolvedModelNameWithFolders(op.fs.model);\n                  color = \"text-info\";\n                  model = modelTypeLabel(op.fs.model);\n                } else if (op.type === \"dbDelete\") {\n                  label = \"delete\";\n                  name = resolvedModelNameWithFolders(op.model);\n                  color = \"text-danger\";\n                  model = modelTypeLabel(op.model);\n                } else {\n                  return null;\n                }\n\n                return (\n                  // oxlint-disable-next-line react/no-array-index-key\n                  <TableRow key={i}>\n                    <TableCell className=\"text-text-subtle\">{model}</TableCell>\n                    <TruncatedWideTableCell>{name}</TruncatedWideTableCell>\n                    <TableCell className=\"text-right\">\n                      <InlineCode className={color}>{label}</InlineCode>\n                    </TableCell>\n                  </TableRow>\n                );\n              })}\n            </TableBody>\n          </Table>\n          <footer className=\"py-3 flex flex-row-reverse items-center gap-3\">\n            <Button type=\"submit\" color=\"primary\">\n              Apply Changes\n            </Button>\n            <Button onClick={hide} color=\"secondary\">\n              Cancel\n            </Button>\n          </footer>\n        </form>\n      ),\n    });\n  },\n});\n"
  },
  {
    "path": "src-web/commands/createEnvironment.tsx",
    "content": "import type { Environment } from \"@yaakapp-internal/models\";\nimport { CreateEnvironmentDialog } from \"../components/CreateEnvironmentDialog\";\nimport { activeWorkspaceIdAtom } from \"../hooks/useActiveWorkspace\";\nimport { createFastMutation } from \"../hooks/useFastMutation\";\nimport { showDialog } from \"../lib/dialog\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { setWorkspaceSearchParams } from \"../lib/setWorkspaceSearchParams\";\n\nexport const createSubEnvironmentAndActivate = createFastMutation<\n  string | null,\n  unknown,\n  Environment | null\n>({\n  mutationKey: [\"create_environment\"],\n  mutationFn: async (baseEnvironment) => {\n    if (baseEnvironment == null) {\n      throw new Error(\"No base environment passed\");\n    }\n\n    const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n    if (workspaceId == null) {\n      throw new Error(\"Cannot create environment when no active workspace\");\n    }\n\n    return new Promise<string | null>((resolve) => {\n      showDialog({\n        id: \"new-environment\",\n        title: \"New Environment\",\n        description: \"Create multiple environments with different sets of variables\",\n        size: \"sm\",\n        onClose: () => resolve(null),\n        render: ({ hide }) => (\n          <CreateEnvironmentDialog\n            workspaceId={workspaceId}\n            hide={hide}\n            onCreate={(id: string) => {\n              resolve(id);\n            }}\n          />\n        ),\n      });\n    });\n  },\n  onSuccess: async (environmentId) => {\n    if (environmentId == null) {\n      return; // Was not created\n    }\n\n    setWorkspaceSearchParams({ environment_id: environmentId });\n  },\n});\n"
  },
  {
    "path": "src-web/commands/deleteWebsocketConnections.ts",
    "content": "import type { WebsocketRequest } from \"@yaakapp-internal/models\";\nimport { deleteWebsocketConnections as cmdDeleteWebsocketConnections } from \"@yaakapp-internal/ws\";\nimport { createFastMutation } from \"../hooks/useFastMutation\";\n\nexport const deleteWebsocketConnections = createFastMutation({\n  mutationKey: [\"delete_websocket_connections\"],\n  mutationFn: async (request: WebsocketRequest) => cmdDeleteWebsocketConnections(request.id),\n});\n"
  },
  {
    "path": "src-web/commands/moveToWorkspace.tsx",
    "content": "import type { GrpcRequest, HttpRequest, WebsocketRequest } from \"@yaakapp-internal/models\";\n\nimport { MoveToWorkspaceDialog } from \"../components/MoveToWorkspaceDialog\";\nimport { activeWorkspaceIdAtom } from \"../hooks/useActiveWorkspace\";\nimport { createFastMutation } from \"../hooks/useFastMutation\";\nimport { pluralizeCount } from \"../lib/pluralize\";\nimport { showDialog } from \"../lib/dialog\";\nimport { jotaiStore } from \"../lib/jotai\";\n\nexport const moveToWorkspace = createFastMutation({\n  mutationKey: [\"move_workspace\"],\n  mutationFn: async (requests: (HttpRequest | GrpcRequest | WebsocketRequest)[]) => {\n    const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n    if (activeWorkspaceId == null) return;\n    if (requests.length === 0) return;\n\n    const title =\n      requests.length === 1 ? \"Move Request\" : `Move ${pluralizeCount(\"Request\", requests.length)}`;\n\n    showDialog({\n      id: \"change-workspace\",\n      title,\n      size: \"sm\",\n      render: ({ hide }) => (\n        <MoveToWorkspaceDialog\n          onDone={hide}\n          requests={requests}\n          activeWorkspaceId={activeWorkspaceId}\n        />\n      ),\n    });\n  },\n});\n"
  },
  {
    "path": "src-web/commands/openFolderSettings.tsx",
    "content": "import { getModel } from \"@yaakapp-internal/models\";\nimport type { FolderSettingsTab } from \"../components/FolderSettingsDialog\";\nimport { FolderSettingsDialog } from \"../components/FolderSettingsDialog\";\nimport { showDialog } from \"../lib/dialog\";\n\nexport function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {\n  const folder = getModel(\"folder\", folderId);\n  if (folder == null) return;\n  showDialog({\n    id: \"folder-settings\",\n    title: null,\n    size: \"lg\",\n    className: \"h-[50rem]\",\n    noPadding: true,\n    render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />,\n  });\n}\n"
  },
  {
    "path": "src-web/commands/openSettings.tsx",
    "content": "import type { SettingsTab } from \"../components/Settings/Settings\";\nimport { activeWorkspaceIdAtom } from \"../hooks/useActiveWorkspace\";\nimport { createFastMutation } from \"../hooks/useFastMutation\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { router } from \"../lib/router\";\nimport { invokeCmd } from \"../lib/tauri\";\n\n// Allow tab with optional subtab (e.g., \"plugins:installed\")\ntype SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;\n\nexport const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({\n  mutationKey: [\"open_settings\"],\n  mutationFn: async (tab) => {\n    const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n    if (workspaceId == null) return;\n\n    const location = router.buildLocation({\n      to: \"/workspaces/$workspaceId/settings\",\n      params: { workspaceId },\n      search: { tab: (tab ?? undefined) as SettingsTab | undefined },\n    });\n\n    await invokeCmd(\"cmd_new_child_window\", {\n      url: location.href,\n      label: \"settings\",\n      title: \"Yaak Settings\",\n      innerSize: [750, 600],\n    });\n  },\n});\n"
  },
  {
    "path": "src-web/commands/openWorkspaceFromSyncDir.tsx",
    "content": "import { applySync, calculateSyncFsOnly } from \"@yaakapp-internal/sync\";\nimport { createFastMutation } from \"../hooks/useFastMutation\";\nimport { showSimpleAlert } from \"../lib/alert\";\nimport { router } from \"../lib/router\";\n\nexport const openWorkspaceFromSyncDir = createFastMutation<void, void, string>({\n  mutationKey: [],\n  mutationFn: async (dir) => {\n    const ops = await calculateSyncFsOnly(dir);\n\n    const workspace = ops\n      .map((o) => (o.type === \"dbCreate\" && o.fs.model.type === \"workspace\" ? o.fs.model : null))\n      .filter((m) => m)[0];\n\n    if (workspace == null) {\n      showSimpleAlert(\"Failed to Open\", \"No workspace found in directory\");\n      return;\n    }\n\n    await applySync(workspace.id, dir, ops);\n\n    await router.navigate({\n      to: \"/workspaces/$workspaceId\",\n      params: { workspaceId: workspace.id },\n    });\n  },\n});\n"
  },
  {
    "path": "src-web/commands/openWorkspaceSettings.tsx",
    "content": "import type { WorkspaceSettingsTab } from \"../components/WorkspaceSettingsDialog\";\nimport { WorkspaceSettingsDialog } from \"../components/WorkspaceSettingsDialog\";\nimport { activeWorkspaceIdAtom } from \"../hooks/useActiveWorkspace\";\nimport { showDialog } from \"../lib/dialog\";\nimport { jotaiStore } from \"../lib/jotai\";\n\nexport function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {\n  const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n  if (workspaceId == null) return;\n  showDialog({\n    id: \"workspace-settings\",\n    size: \"md\",\n    className: \"h-[calc(100vh-5rem)] !max-h-[40rem]\",\n    noPadding: true,\n    render: ({ hide }) => (\n      <WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />\n    ),\n  });\n}\n"
  },
  {
    "path": "src-web/commands/switchWorkspace.tsx",
    "content": "import { createFastMutation } from \"../hooks/useFastMutation\";\nimport { getRecentCookieJars } from \"../hooks/useRecentCookieJars\";\nimport { getRecentEnvironments } from \"../hooks/useRecentEnvironments\";\nimport { getRecentRequests } from \"../hooks/useRecentRequests\";\nimport { router } from \"../lib/router\";\nimport { invokeCmd } from \"../lib/tauri\";\n\nexport const switchWorkspace = createFastMutation<\n  void,\n  unknown,\n  {\n    workspaceId: string;\n    inNewWindow: boolean;\n  }\n>({\n  mutationKey: [\"open_workspace\"],\n  mutationFn: async ({ workspaceId, inNewWindow }) => {\n    const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? undefined;\n    const requestId = (await getRecentRequests(workspaceId))[0] ?? undefined;\n    const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? undefined;\n    const search = {\n      environment_id: environmentId,\n      cookie_jar_id: cookieJarId,\n      request_id: requestId,\n    };\n\n    if (inNewWindow) {\n      const location = router.buildLocation({\n        to: \"/workspaces/$workspaceId\",\n        params: { workspaceId },\n        search,\n      });\n      await invokeCmd<void>(\"cmd_new_main_window\", { url: location.href });\n      return;\n    }\n\n    await router.navigate({\n      to: \"/workspaces/$workspaceId\",\n      params: { workspaceId },\n      search,\n    });\n  },\n});\n"
  },
  {
    "path": "src-web/components/BinaryFileEditor.tsx",
    "content": "import type { HttpRequest } from \"@yaakapp-internal/models\";\nimport mime from \"mime\";\nimport { useKeyValue } from \"../hooks/useKeyValue\";\nimport { Banner } from \"./core/Banner\";\nimport { Button } from \"./core/Button\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport { HStack, VStack } from \"./core/Stacks\";\nimport { SelectFile } from \"./SelectFile\";\n\ntype Props = {\n  requestId: string;\n  contentType: string | null;\n  body: HttpRequest[\"body\"];\n  onChange: (body: HttpRequest[\"body\"]) => void;\n  onChangeContentType: (contentType: string | null) => void;\n};\n\nexport function BinaryFileEditor({\n  contentType,\n  body,\n  onChange,\n  onChangeContentType,\n  requestId,\n}: Props) {\n  const ignoreContentType = useKeyValue<boolean>({\n    namespace: \"global\",\n    key: [\"ignore_content_type\", requestId],\n    fallback: false,\n  });\n\n  const handleChange = async ({ filePath }: { filePath: string | null }) => {\n    await ignoreContentType.set(false);\n    onChange({ filePath: filePath ?? undefined });\n  };\n\n  const filePath = typeof body.filePath === \"string\" ? body.filePath : null;\n  const mimeType = mime.getType(filePath ?? \"\") ?? \"application/octet-stream\";\n\n  return (\n    <VStack space={2}>\n      <SelectFile onChange={handleChange} filePath={filePath} />\n      {filePath != null && mimeType !== contentType && !ignoreContentType.value && (\n        <Banner className=\"mt-3 !py-5\">\n          <div className=\"mb-4 text-center\">\n            <div>Set Content-Type header</div>\n            <InlineCode>{mimeType}</InlineCode> for current request?\n          </div>\n          <HStack space={1.5} justifyContent=\"center\">\n            <Button size=\"sm\" variant=\"border\" onClick={() => ignoreContentType.set(true)}>\n              Ignore\n            </Button>\n            <Button\n              variant=\"solid\"\n              color=\"primary\"\n              size=\"sm\"\n              onClick={() => onChangeContentType(mimeType)}\n            >\n              Set Header\n            </Button>\n          </HStack>\n        </Banner>\n      )}\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/CargoFeature.tsx",
    "content": "import type { ReactNode } from \"react\";\nimport { appInfo } from \"../lib/appInfo\";\n\ninterface Props {\n  children: ReactNode;\n  feature: \"updater\" | \"license\";\n}\n\nconst featureMap: Record<Props[\"feature\"], boolean> = {\n  updater: appInfo.featureUpdater,\n  license: appInfo.featureLicense,\n};\n\nexport function CargoFeature({ children, feature }: Props) {\n  if (featureMap[feature]) {\n    return <>{children}</>;\n  }\n  return null;\n}\n"
  },
  {
    "path": "src-web/components/CloneGitRepositoryDialog.tsx",
    "content": "import { open } from \"@tauri-apps/plugin-dialog\";\nimport { gitClone } from \"@yaakapp-internal/git\";\nimport { useState } from \"react\";\nimport { openWorkspaceFromSyncDir } from \"../commands/openWorkspaceFromSyncDir\";\nimport { appInfo } from \"../lib/appInfo\";\nimport { showErrorToast } from \"../lib/toast\";\nimport { Banner } from \"./core/Banner\";\nimport { Button } from \"./core/Button\";\nimport { Checkbox } from \"./core/Checkbox\";\nimport { IconButton } from \"./core/IconButton\";\nimport { PlainInput } from \"./core/PlainInput\";\nimport { VStack } from \"./core/Stacks\";\nimport { promptCredentials } from \"./git/credentials\";\n\ninterface Props {\n  hide: () => void;\n}\n\n// Detect path separator from an existing path (defaults to /)\nfunction getPathSeparator(path: string): string {\n  return path.includes(\"\\\\\") ? \"\\\\\" : \"/\";\n}\n\nexport function CloneGitRepositoryDialog({ hide }: Props) {\n  const [url, setUrl] = useState<string>(\"\");\n  const [baseDirectory, setBaseDirectory] = useState<string>(appInfo.defaultProjectDir);\n  const [directoryOverride, setDirectoryOverride] = useState<string | null>(null);\n  const [hasSubdirectory, setHasSubdirectory] = useState(false);\n  const [subdirectory, setSubdirectory] = useState<string>(\"\");\n  const [isCloning, setIsCloning] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const repoName = extractRepoName(url);\n  const sep = getPathSeparator(baseDirectory);\n  const computedDirectory = repoName ? `${baseDirectory}${sep}${repoName}` : baseDirectory;\n  const directory = directoryOverride ?? computedDirectory;\n  const workspaceDirectory =\n    hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;\n\n  const handleSelectDirectory = async () => {\n    const dir = await open({\n      title: \"Select Directory\",\n      directory: true,\n      multiple: false,\n    });\n    if (dir != null) {\n      setBaseDirectory(dir);\n      setDirectoryOverride(null);\n    }\n  };\n\n  const handleClone = async (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!url || !directory) return;\n\n    setIsCloning(true);\n    setError(null);\n\n    try {\n      const result = await gitClone(url, directory, promptCredentials);\n\n      if (result.type === \"needs_credentials\") {\n        setError(\n          result.error ?? \"Authentication failed. Please check your credentials and try again.\",\n        );\n        return;\n      }\n\n      // Open the workspace from the cloned directory (or subdirectory)\n      await openWorkspaceFromSyncDir.mutateAsync(workspaceDirectory);\n\n      hide();\n    } catch (err) {\n      setError(String(err));\n      showErrorToast({\n        id: \"git-clone-error\",\n        title: \"Clone Failed\",\n        message: String(err),\n      });\n    } finally {\n      setIsCloning(false);\n    }\n  };\n\n  return (\n    <VStack as=\"form\" space={3} alignItems=\"start\" className=\"pb-3\" onSubmit={handleClone}>\n      {error && (\n        <Banner color=\"danger\" className=\"w-full\">\n          {error}\n        </Banner>\n      )}\n\n      <PlainInput\n        required\n        label=\"Repository URL\"\n        placeholder=\"https://github.com/user/repo.git\"\n        defaultValue={url}\n        onChange={setUrl}\n      />\n\n      <PlainInput\n        label=\"Directory\"\n        placeholder={appInfo.defaultProjectDir}\n        defaultValue={directory}\n        onChange={setDirectoryOverride}\n        rightSlot={\n          <IconButton\n            size=\"xs\"\n            className=\"mr-0.5 !h-auto my-0.5\"\n            icon=\"folder\"\n            title=\"Browse\"\n            onClick={handleSelectDirectory}\n          />\n        }\n      />\n\n      <Checkbox\n        checked={hasSubdirectory}\n        onChange={setHasSubdirectory}\n        title=\"Workspace is in a subdirectory\"\n        help=\"Enable if the Yaak workspace files are not at the root of the repository\"\n      />\n\n      {hasSubdirectory && (\n        <PlainInput\n          label=\"Subdirectory\"\n          placeholder=\"path/to/workspace\"\n          defaultValue={subdirectory}\n          onChange={setSubdirectory}\n        />\n      )}\n\n      <Button\n        type=\"submit\"\n        color=\"primary\"\n        className=\"w-full mt-3\"\n        disabled={!url || !directory || isCloning}\n        isLoading={isCloning}\n      >\n        {isCloning ? \"Cloning...\" : \"Clone Repository\"}\n      </Button>\n    </VStack>\n  );\n}\n\nfunction extractRepoName(url: string): string {\n  // Handle various Git URL formats:\n  // https://github.com/user/repo.git\n  // git@github.com:user/repo.git\n  // https://github.com/user/repo\n  const match = url.match(/\\/([^/]+?)(\\.git)?$/);\n  if (match?.[1]) {\n    return match[1];\n  }\n  // Fallback for SSH-style URLs\n  const sshMatch = url.match(/:([^/]+?)(\\.git)?$/);\n  if (sshMatch?.[1]) {\n    return sshMatch[1];\n  }\n  return \"\";\n}\n"
  },
  {
    "path": "src-web/components/ColorIndicator.tsx",
    "content": "import classNames from \"classnames\";\nimport type { CSSProperties } from \"react\";\n\ninterface Props {\n  color: string | null;\n  onClick?: () => void;\n  className?: string;\n}\n\nexport function ColorIndicator({ color, onClick, className }: Props) {\n  const style: CSSProperties = { backgroundColor: color ?? undefined };\n  const finalClassName = classNames(\n    className,\n    \"inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent flex-shrink-0\",\n  );\n\n  if (onClick) {\n    return (\n      <button\n        type=\"button\"\n        onClick={onClick}\n        style={style}\n        className={classNames(finalClassName, \"hover:border-text\")}\n      />\n    );\n  }\n  return <span style={style} className={finalClassName} />;\n}\n"
  },
  {
    "path": "src-web/components/CommandPaletteDialog.tsx",
    "content": "import { workspacesAtom } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { fuzzyFilter } from \"fuzzbunny\";\nimport { useAtomValue } from \"jotai\";\nimport {\n  Fragment,\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createFolder } from \"../commands/commands\";\nimport { createSubEnvironmentAndActivate } from \"../commands/createEnvironment\";\nimport { openSettings } from \"../commands/openSettings\";\nimport { switchWorkspace } from \"../commands/switchWorkspace\";\nimport { useActiveCookieJar } from \"../hooks/useActiveCookieJar\";\nimport { useActiveEnvironment } from \"../hooks/useActiveEnvironment\";\nimport { useActiveRequest } from \"../hooks/useActiveRequest\";\nimport { activeWorkspaceIdAtom } from \"../hooks/useActiveWorkspace\";\nimport { useAllRequests } from \"../hooks/useAllRequests\";\nimport { useCreateWorkspace } from \"../hooks/useCreateWorkspace\";\nimport { useDebouncedState } from \"../hooks/useDebouncedState\";\nimport { useEnvironmentsBreakdown } from \"../hooks/useEnvironmentsBreakdown\";\nimport { useGrpcRequestActions } from \"../hooks/useGrpcRequestActions\";\nimport type { HotkeyAction } from \"../hooks/useHotKey\";\nimport { useHttpRequestActions } from \"../hooks/useHttpRequestActions\";\nimport { useRecentEnvironments } from \"../hooks/useRecentEnvironments\";\nimport { useRecentRequests } from \"../hooks/useRecentRequests\";\nimport { useRecentWorkspaces } from \"../hooks/useRecentWorkspaces\";\nimport { useScrollIntoView } from \"../hooks/useScrollIntoView\";\nimport { useSendAnyHttpRequest } from \"../hooks/useSendAnyHttpRequest\";\nimport { useSidebarHidden } from \"../hooks/useSidebarHidden\";\nimport { appInfo } from \"../lib/appInfo\";\nimport { copyToClipboard } from \"../lib/copy\";\nimport { createRequestAndNavigate } from \"../lib/createRequestAndNavigate\";\nimport { deleteModelWithConfirm } from \"../lib/deleteModelWithConfirm\";\nimport { showDialog } from \"../lib/dialog\";\nimport { editEnvironment } from \"../lib/editEnvironment\";\nimport { renameModelWithPrompt } from \"../lib/renameModelWithPrompt\";\nimport {\n  resolvedModelNameWithFolders,\n  resolvedModelNameWithFoldersArray,\n} from \"../lib/resolvedModelName\";\nimport { router } from \"../lib/router\";\nimport { setWorkspaceSearchParams } from \"../lib/setWorkspaceSearchParams\";\nimport { CookieDialog } from \"./CookieDialog\";\nimport { Button } from \"./core/Button\";\nimport { Heading } from \"./core/Heading\";\nimport { Hotkey } from \"./core/Hotkey\";\nimport { HttpMethodTag } from \"./core/HttpMethodTag\";\nimport { Icon } from \"./core/Icon\";\nimport { PlainInput } from \"./core/PlainInput\";\n\ninterface CommandPaletteGroup {\n  key: string;\n  label: ReactNode;\n  items: CommandPaletteItem[];\n}\n\ntype CommandPaletteItem = {\n  key: string;\n  onSelect: () => void;\n  action?: HotkeyAction;\n} & ({ searchText: string; label: ReactNode } | { label: string });\n\nconst MAX_PER_GROUP = 8;\n\nexport function CommandPaletteDialog({ onClose }: { onClose: () => void }) {\n  const [command, setCommand] = useDebouncedState<string>(\"\", 150);\n  const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);\n  const activeEnvironment = useActiveEnvironment();\n  const httpRequestActions = useHttpRequestActions();\n  const grpcRequestActions = useGrpcRequestActions();\n  const workspaceId = useAtomValue(activeWorkspaceIdAtom);\n  const workspaces = useAtomValue(workspacesAtom);\n  const { baseEnvironment, subEnvironments } = useEnvironmentsBreakdown();\n  const createWorkspace = useCreateWorkspace();\n  const recentEnvironments = useRecentEnvironments();\n  const recentWorkspaces = useRecentWorkspaces();\n  const requests = useAllRequests();\n  const activeRequest = useActiveRequest();\n  const activeCookieJar = useActiveCookieJar();\n  const [recentRequests] = useRecentRequests();\n  const [, setSidebarHidden] = useSidebarHidden();\n  const { mutate: sendRequest } = useSendAnyHttpRequest();\n\n  const handleSetCommand = (command: string) => {\n    setCommand(command);\n    setSelectedItemKey(null);\n  };\n\n  const workspaceCommands = useMemo<CommandPaletteItem[]>(() => {\n    if (workspaceId == null) return [];\n\n    const commands: CommandPaletteItem[] = [\n      {\n        key: \"settings.open\",\n        label: \"Open Settings\",\n        action: \"settings.show\",\n        onSelect: () => openSettings.mutate(null),\n      },\n      {\n        key: \"app.create\",\n        label: \"Create Workspace\",\n        onSelect: createWorkspace,\n      },\n      {\n        key: \"model.create\",\n        label: \"Create HTTP Request\",\n        onSelect: () => createRequestAndNavigate({ model: \"http_request\", workspaceId }),\n      },\n      {\n        key: \"grpc_request.create\",\n        label: \"Create GRPC Request\",\n        onSelect: () => createRequestAndNavigate({ model: \"grpc_request\", workspaceId }),\n      },\n      {\n        key: \"websocket_request.create\",\n        label: \"Create Websocket Request\",\n        onSelect: () => createRequestAndNavigate({ model: \"websocket_request\", workspaceId }),\n      },\n      {\n        key: \"folder.create\",\n        label: \"Create Folder\",\n        onSelect: () => createFolder.mutate({}),\n      },\n      {\n        key: \"cookies.show\",\n        label: \"Show Cookies\",\n        onSelect: async () => {\n          showDialog({\n            id: \"cookies\",\n            title: \"Manage Cookies\",\n            size: \"full\",\n            render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,\n          });\n        },\n      },\n      {\n        key: \"environment.edit\",\n        label: \"Edit Environment\",\n        action: \"environment_editor.toggle\",\n        onSelect: () => editEnvironment(activeEnvironment),\n      },\n      {\n        key: \"environment.create\",\n        label: \"Create Environment\",\n        onSelect: () => createSubEnvironmentAndActivate.mutate(baseEnvironment),\n      },\n      {\n        key: \"sidebar.toggle\",\n        label: \"Toggle Sidebar\",\n        action: \"sidebar.focus\",\n        onSelect: () => setSidebarHidden((h) => !h),\n      },\n    ];\n\n    if (activeRequest?.model === \"http_request\") {\n      commands.push({\n        key: \"request.send\",\n        action: \"request.send\",\n        label: \"Send Request\",\n        onSelect: () => sendRequest(activeRequest.id),\n      });\n      if (appInfo.cliVersion != null) {\n        commands.push({\n          key: \"request.copy_cli_send\",\n          searchText: `copy cli send yaak request send ${activeRequest.id}`,\n          label: \"Copy CLI Send Command\",\n          onSelect: () => copyToClipboard(`yaak request send ${activeRequest.id}`),\n        });\n      }\n      httpRequestActions.forEach((a, i) => {\n        commands.push({\n          key: `http_request_action.${i}`,\n          label: a.label,\n          onSelect: () => a.call(activeRequest),\n        });\n      });\n    }\n\n    if (activeRequest?.model === \"grpc_request\") {\n      grpcRequestActions.forEach((a, i) => {\n        commands.push({\n          key: `grpc_request_action.${i}`,\n          label: a.label,\n          onSelect: () => a.call(activeRequest),\n        });\n      });\n    }\n\n    if (activeRequest != null) {\n      commands.push({\n        key: \"http_request.rename\",\n        label: \"Rename Request\",\n        onSelect: () => renameModelWithPrompt(activeRequest),\n      });\n\n      commands.push({\n        key: \"sidebar.selected.delete\",\n        label: \"Delete Request\",\n        onSelect: () => deleteModelWithConfirm(activeRequest),\n      });\n    }\n\n    return commands.sort((a, b) =>\n      (\"searchText\" in a ? a.searchText : a.label).localeCompare(\n        \"searchText\" in b ? b.searchText : b.label,\n      ),\n    );\n  }, [\n    activeCookieJar?.id,\n    activeEnvironment,\n    activeRequest,\n    baseEnvironment,\n    createWorkspace,\n    grpcRequestActions,\n    httpRequestActions,\n    sendRequest,\n    setSidebarHidden,\n    workspaceId,\n  ]);\n\n  const sortedRequests = useMemo(() => {\n    return [...requests].sort((a, b) => {\n      const aRecentIndex = recentRequests.indexOf(a.id);\n      const bRecentIndex = recentRequests.indexOf(b.id);\n\n      if (aRecentIndex >= 0 && bRecentIndex >= 0) {\n        return aRecentIndex - bRecentIndex;\n      }\n      if (aRecentIndex >= 0 && bRecentIndex === -1) {\n        return -1;\n      }\n      if (aRecentIndex === -1 && bRecentIndex >= 0) {\n        return 1;\n      }\n      return a.createdAt.localeCompare(b.createdAt);\n    });\n  }, [recentRequests, requests]);\n\n  const sortedEnvironments = useMemo(() => {\n    return [...subEnvironments].sort((a, b) => {\n      const aRecentIndex = recentEnvironments.indexOf(a.id);\n      const bRecentIndex = recentEnvironments.indexOf(b.id);\n\n      if (aRecentIndex >= 0 && bRecentIndex >= 0) {\n        return aRecentIndex - bRecentIndex;\n      }\n      if (aRecentIndex >= 0 && bRecentIndex === -1) {\n        return -1;\n      }\n      if (aRecentIndex === -1 && bRecentIndex >= 0) {\n        return 1;\n      }\n      return a.createdAt.localeCompare(b.createdAt);\n    });\n  }, [subEnvironments, recentEnvironments]);\n\n  const sortedWorkspaces = useMemo(() => {\n    if (recentWorkspaces == null) {\n      // Should never happen\n      return workspaces;\n    }\n\n    return [...workspaces].sort((a, b) => {\n      const aRecentIndex = recentWorkspaces?.indexOf(a.id);\n      const bRecentIndex = recentWorkspaces?.indexOf(b.id);\n\n      if (aRecentIndex >= 0 && bRecentIndex >= 0) {\n        return aRecentIndex - bRecentIndex;\n      }\n      if (aRecentIndex >= 0 && bRecentIndex === -1) {\n        return -1;\n      }\n      if (aRecentIndex === -1 && bRecentIndex >= 0) {\n        return 1;\n      }\n      return a.createdAt.localeCompare(b.createdAt);\n    });\n  }, [recentWorkspaces, workspaces]);\n\n  const groups = useMemo<CommandPaletteGroup[]>(() => {\n    const actionsGroup: CommandPaletteGroup = {\n      key: \"actions\",\n      label: \"Actions\",\n      items: workspaceCommands,\n    };\n\n    const requestGroup: CommandPaletteGroup = {\n      key: \"requests\",\n      label: \"Switch Request\",\n      items: [],\n    };\n\n    for (const r of sortedRequests) {\n      requestGroup.items.push({\n        key: `switch-request-${r.id}`,\n        searchText: resolvedModelNameWithFolders(r),\n        label: (\n          <div className=\"flex items-center gap-x-0.5\">\n            <HttpMethodTag short className=\"text-xs mr-2\" request={r} />\n            {resolvedModelNameWithFoldersArray(r).map((name, i, all) => (\n              <Fragment key={name}>\n                {i !== 0 && <Icon icon=\"chevron_right\" className=\"opacity-80\" />}\n                <div className={classNames(i < all.length - 1 && \"truncate\")}>{name}</div>\n              </Fragment>\n            ))}\n          </div>\n        ),\n        onSelect: async () => {\n          await router.navigate({\n            to: \"/workspaces/$workspaceId\",\n            params: { workspaceId: r.workspaceId },\n            search: (prev) => ({ ...prev, request_id: r.id }),\n          });\n        },\n      });\n    }\n\n    const environmentGroup: CommandPaletteGroup = {\n      key: \"environments\",\n      label: \"Switch Environment\",\n      items: [],\n    };\n\n    for (const e of sortedEnvironments) {\n      if (e.id === activeEnvironment?.id) {\n        continue;\n      }\n      environmentGroup.items.push({\n        key: `switch-environment-${e.id}`,\n        label: e.name,\n        onSelect: () => setWorkspaceSearchParams({ environment_id: e.id }),\n      });\n    }\n\n    const workspaceGroup: CommandPaletteGroup = {\n      key: \"workspaces\",\n      label: \"Switch Workspace\",\n      items: [],\n    };\n\n    for (const w of sortedWorkspaces) {\n      workspaceGroup.items.push({\n        key: `switch-workspace-${w.id}`,\n        label: w.name,\n        onSelect: () => switchWorkspace.mutate({ workspaceId: w.id, inNewWindow: false }),\n      });\n    }\n\n    return [actionsGroup, requestGroup, environmentGroup, workspaceGroup];\n  }, [\n    workspaceCommands,\n    sortedRequests,\n    sortedEnvironments,\n    activeEnvironment?.id,\n    sortedWorkspaces,\n  ]);\n\n  const allItems = useMemo(() => groups.flatMap((g) => g.items), [groups]);\n\n  const { filteredGroups, filteredAllItems } = useMemo(() => {\n    const result = command\n      ? fuzzyFilter(\n          allItems.map((i) => ({\n            ...i,\n            filterBy: \"searchText\" in i ? i.searchText : i.label,\n          })),\n          command,\n          { fields: [\"filterBy\"] },\n        )\n          .sort((a, b) => b.score - a.score)\n          .map((v) => v.item)\n      : allItems;\n\n    const filteredGroups = groups\n      .map((g) => {\n        const items = result\n          .filter((i) => g.items.find((i2) => i2.key === i.key))\n          .slice(0, MAX_PER_GROUP);\n        return { ...g, items };\n      })\n      .filter((g) => g.items.length > 0);\n\n    const filteredAllItems = filteredGroups.flatMap((g) => g.items);\n    return { filteredAllItems, filteredGroups };\n  }, [allItems, command, groups]);\n\n  const handleSelectAndClose = useCallback(\n    (cb: () => void) => {\n      onClose();\n      cb();\n    },\n    [onClose],\n  );\n\n  const selectedItem = useMemo(() => {\n    let selectedItem = filteredAllItems.find((i) => i.key === selectedItemKey) ?? null;\n    if (selectedItem == null) {\n      selectedItem = filteredAllItems[0] ?? null;\n    }\n    return selectedItem;\n  }, [filteredAllItems, selectedItemKey]);\n\n  const handleKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLInputElement>) => {\n      const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);\n      if (e.key === \"ArrowDown\" || (e.ctrlKey && e.key === \"n\")) {\n        const next = filteredAllItems[index + 1] ?? filteredAllItems[0];\n        setSelectedItemKey(next?.key ?? null);\n      } else if (e.key === \"ArrowUp\" || (e.ctrlKey && e.key === \"k\")) {\n        const prev = filteredAllItems[index - 1] ?? filteredAllItems[filteredAllItems.length - 1];\n        setSelectedItemKey(prev?.key ?? null);\n      } else if (e.key === \"Enter\") {\n        const selected = filteredAllItems[index];\n        setSelectedItemKey(selected?.key ?? null);\n        if (selected) {\n          handleSelectAndClose(selected.onSelect);\n        }\n      }\n    },\n    [filteredAllItems, handleSelectAndClose, selectedItem?.key],\n  );\n\n  return (\n    <div className=\"h-full w-[min(700px,80vw)] grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden py-2\">\n      <div className=\"px-2 w-full\">\n        <PlainInput\n          autoFocus\n          hideLabel\n          leftSlot={\n            <div className=\"h-md w-10 flex justify-center items-center\">\n              <Icon icon=\"search\" color=\"secondary\" />\n            </div>\n          }\n          name=\"command\"\n          label=\"Command\"\n          placeholder=\"Search or type a command\"\n          className=\"font-sans !text-base\"\n          defaultValue={command}\n          onChange={handleSetCommand}\n          onKeyDownCapture={handleKeyDown}\n        />\n      </div>\n      <div className=\"h-full px-1.5 overflow-y-auto pt-2 pb-1\">\n        {filteredGroups.map((g) => (\n          <div key={g.key} className=\"mb-1.5 w-full\">\n            <Heading level={2} className=\"!text-xs uppercase px-1.5 h-sm flex items-center\">\n              {g.label}\n            </Heading>\n            {g.items.map((v) => (\n              <CommandPaletteItem\n                active={v.key === selectedItem?.key}\n                key={v.key}\n                onClick={() => handleSelectAndClose(v.onSelect)}\n                rightSlot={v.action && <CommandPaletteAction action={v.action} />}\n              >\n                {v.label}\n              </CommandPaletteItem>\n            ))}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction CommandPaletteItem({\n  children,\n  active,\n  onClick,\n  rightSlot,\n}: {\n  children: ReactNode;\n  active: boolean;\n  onClick: () => void;\n  rightSlot?: ReactNode;\n}) {\n  const ref = useRef<HTMLButtonElement | null>(null);\n  useScrollIntoView(ref.current, active);\n\n  return (\n    <Button\n      ref={ref}\n      onClick={onClick}\n      tabIndex={active ? undefined : -1}\n      rightSlot={rightSlot}\n      color=\"custom\"\n      justify=\"start\"\n      className={classNames(\n        \"w-full h-sm flex items-center rounded px-1.5\",\n        \"hover:text-text\",\n        active && \"bg-surface-highlight\",\n        !active && \"text-text-subtle\",\n      )}\n    >\n      <span className=\"truncate\">{children}</span>\n    </Button>\n  );\n}\n\nfunction CommandPaletteAction({ action }: { action: HotkeyAction }) {\n  return <Hotkey className=\"ml-auto\" action={action} />;\n}\n"
  },
  {
    "path": "src-web/components/ConfirmLargeRequestBody.tsx",
    "content": "import type { HttpRequest } from \"@yaakapp-internal/models\";\nimport { patchModel } from \"@yaakapp-internal/models\";\nimport type { ReactNode } from \"react\";\nimport { useToggle } from \"../hooks/useToggle\";\nimport { showConfirm } from \"../lib/confirm\";\nimport { Banner } from \"./core/Banner\";\nimport { Button } from \"./core/Button\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport { Link } from \"./core/Link\";\nimport { SizeTag } from \"./core/SizeTag\";\nimport { HStack } from \"./core/Stacks\";\n\ninterface Props {\n  children: ReactNode;\n  request: HttpRequest;\n}\n\nconst LARGE_TEXT_BYTES = 2 * 1000 * 1000;\n\nexport function ConfirmLargeRequestBody({ children, request }: Props) {\n  const [showLargeResponse, toggleShowLargeResponse] = useToggle();\n\n  if (request.body?.text == null) {\n    return children;\n  }\n\n  const contentLength = request.body.text.length ?? 0;\n  const tooLargeBytes = LARGE_TEXT_BYTES;\n  const isLarge = contentLength > tooLargeBytes;\n  if (!showLargeResponse && isLarge) {\n    return (\n      <Banner color=\"primary\" className=\"flex flex-col gap-3\">\n        <p>\n          Rendering content over{\" \"}\n          <InlineCode>\n            <SizeTag contentLength={tooLargeBytes} />\n          </InlineCode>{\" \"}\n          may impact performance.\n        </p>\n        <p>\n          See{\" \"}\n          <Link href=\"https://feedback.yaak.app/en/help/articles/1198684-working-with-large-values\">\n            Working With Large Values\n          </Link>{\" \"}\n          for tips.\n        </p>\n        <HStack wrap space={2}>\n          <Button color=\"primary\" size=\"xs\" onClick={toggleShowLargeResponse}>\n            Reveal Body\n          </Button>\n          <Button\n            color=\"danger\"\n            size=\"xs\"\n            variant=\"border\"\n            onClick={async () => {\n              const confirm = await showConfirm({\n                id: `delete-body-${request.id}`,\n                confirmText: \"Delete Body\",\n                title: \"Delete Body Text\",\n                description: \"Are you sure you want to delete the request body text?\",\n                color: \"danger\",\n              });\n              if (confirm) {\n                await patchModel(request, { body: { ...request.body, text: \"\" } });\n              }\n            }}\n          >\n            Delete Body\n          </Button>\n        </HStack>\n      </Banner>\n    );\n  }\n\n  return <>{children}</>;\n}\n"
  },
  {
    "path": "src-web/components/ConfirmLargeResponse.tsx",
    "content": "import type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { type ReactNode, useMemo } from \"react\";\nimport { useSaveResponse } from \"../hooks/useSaveResponse\";\nimport { useToggle } from \"../hooks/useToggle\";\nimport { isProbablyTextContentType } from \"../lib/contentType\";\nimport { getContentTypeFromHeaders } from \"../lib/model_util\";\nimport { getResponseBodyText } from \"../lib/responseBody\";\nimport { CopyButton } from \"./CopyButton\";\nimport { Banner } from \"./core/Banner\";\nimport { Button } from \"./core/Button\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport { SizeTag } from \"./core/SizeTag\";\nimport { HStack } from \"./core/Stacks\";\n\ninterface Props {\n  children: ReactNode;\n  response: HttpResponse;\n}\n\nconst LARGE_BYTES = 2 * 1000 * 1000;\n\nexport function ConfirmLargeResponse({ children, response }: Props) {\n  const { mutate: saveResponse } = useSaveResponse(response);\n  const [showLargeResponse, toggleShowLargeResponse] = useToggle();\n  const isProbablyText = useMemo(() => {\n    const contentType = getContentTypeFromHeaders(response.headers);\n    return isProbablyTextContentType(contentType);\n  }, [response.headers]);\n\n  const contentLength = response.contentLength ?? 0;\n  const isLarge = contentLength > LARGE_BYTES;\n  if (!showLargeResponse && isLarge) {\n    return (\n      <Banner color=\"primary\" className=\"flex flex-col gap-3\">\n        <p>\n          Showing responses over{\" \"}\n          <InlineCode>\n            <SizeTag contentLength={LARGE_BYTES} />\n          </InlineCode>{\" \"}\n          may impact performance\n        </p>\n        <HStack wrap space={2}>\n          <Button color=\"primary\" size=\"xs\" onClick={toggleShowLargeResponse}>\n            Reveal Response\n          </Button>\n          <Button color=\"secondary\" variant=\"border\" size=\"xs\" onClick={() => saveResponse()}>\n            Save to File\n          </Button>\n          {isProbablyText && (\n            <CopyButton\n              color=\"secondary\"\n              variant=\"border\"\n              size=\"xs\"\n              text={() => getResponseBodyText({ response, filter: null })}\n            />\n          )}\n        </HStack>\n      </Banner>\n    );\n  }\n\n  return <>{children}</>;\n}\n"
  },
  {
    "path": "src-web/components/ConfirmLargeResponseRequest.tsx",
    "content": "import type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { type ReactNode, useMemo } from \"react\";\nimport { getRequestBodyText as getHttpResponseRequestBodyText } from \"../hooks/useHttpRequestBody\";\nimport { useToggle } from \"../hooks/useToggle\";\nimport { isProbablyTextContentType } from \"../lib/contentType\";\nimport { getContentTypeFromHeaders } from \"../lib/model_util\";\nimport { CopyButton } from \"./CopyButton\";\nimport { Banner } from \"./core/Banner\";\nimport { Button } from \"./core/Button\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport { SizeTag } from \"./core/SizeTag\";\nimport { HStack } from \"./core/Stacks\";\n\ninterface Props {\n  children: ReactNode;\n  response: HttpResponse;\n}\n\nconst LARGE_BYTES = 2 * 1000 * 1000;\n\nexport function ConfirmLargeResponseRequest({ children, response }: Props) {\n  const [showLargeResponse, toggleShowLargeResponse] = useToggle();\n  const isProbablyText = useMemo(() => {\n    const contentType = getContentTypeFromHeaders(response.headers);\n    return isProbablyTextContentType(contentType);\n  }, [response.headers]);\n\n  const contentLength = response.requestContentLength ?? 0;\n  const isLarge = contentLength > LARGE_BYTES;\n  if (!showLargeResponse && isLarge) {\n    return (\n      <Banner color=\"primary\" className=\"flex flex-col gap-3\">\n        <p>\n          Showing content over{\" \"}\n          <InlineCode>\n            <SizeTag contentLength={LARGE_BYTES} />\n          </InlineCode>{\" \"}\n          may impact performance\n        </p>\n        <HStack wrap space={2}>\n          <Button color=\"primary\" size=\"xs\" onClick={toggleShowLargeResponse}>\n            Reveal Request Body\n          </Button>\n          {isProbablyText && (\n            <CopyButton\n              color=\"secondary\"\n              variant=\"border\"\n              size=\"xs\"\n              text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? \"\")}\n            />\n          )}\n        </HStack>\n      </Banner>\n    );\n  }\n\n  return <>{children}</>;\n}\n"
  },
  {
    "path": "src-web/components/CookieDialog.tsx",
    "content": "import type { Cookie } from \"@yaakapp-internal/models\";\nimport { cookieJarsAtom, patchModel } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { cookieDomain } from \"../lib/model_util\";\nimport { Banner } from \"./core/Banner\";\nimport { IconButton } from \"./core/IconButton\";\nimport { InlineCode } from \"./core/InlineCode\";\n\ninterface Props {\n  cookieJarId: string | null;\n}\n\nexport const CookieDialog = ({ cookieJarId }: Props) => {\n  const cookieJars = useAtomValue(cookieJarsAtom);\n  const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);\n\n  if (cookieJar == null) {\n    return <div>No cookie jar selected</div>;\n  }\n\n  if (cookieJar.cookies.length === 0) {\n    return (\n      <Banner>\n        Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode> header\n      </Banner>\n    );\n  }\n\n  return (\n    <div className=\"pb-2\">\n      <table className=\"w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight\">\n        <thead>\n          <tr>\n            <th className=\"py-2 text-left\">Domain</th>\n            <th className=\"py-2 text-left pl-4\">Cookie</th>\n            <th className=\"py-2 pl-4\" />\n          </tr>\n        </thead>\n        <tbody className=\"divide-y divide-surface-highlight\">\n          {cookieJar?.cookies.map((c: Cookie) => (\n            <tr key={JSON.stringify(c)}>\n              <td className=\"py-2 select-text cursor-text font-mono font-semibold max-w-0\">\n                {cookieDomain(c)}\n              </td>\n              <td className=\"py-2 pl-4 select-text cursor-text font-mono text-text-subtle whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars\">\n                {c.raw_cookie}\n              </td>\n              <td className=\"max-w-0 w-10\">\n                <IconButton\n                  icon=\"trash\"\n                  size=\"xs\"\n                  iconSize=\"sm\"\n                  title=\"Delete\"\n                  className=\"ml-auto\"\n                  onClick={() =>\n                    patchModel(cookieJar, {\n                      cookies: cookieJar.cookies.filter((c2: Cookie) => c2 !== c),\n                    })\n                  }\n                />\n              </td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src-web/components/CookieDropdown.tsx",
    "content": "import { cookieJarsAtom, patchModel } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useMemo } from \"react\";\nimport { useActiveCookieJar } from \"../hooks/useActiveCookieJar\";\nimport { useCreateCookieJar } from \"../hooks/useCreateCookieJar\";\nimport { deleteModelWithConfirm } from \"../lib/deleteModelWithConfirm\";\nimport { showDialog } from \"../lib/dialog\";\nimport { showPrompt } from \"../lib/prompt\";\nimport { setWorkspaceSearchParams } from \"../lib/setWorkspaceSearchParams\";\nimport { CookieDialog } from \"./CookieDialog\";\nimport { Dropdown, type DropdownItem } from \"./core/Dropdown\";\nimport { Icon } from \"./core/Icon\";\nimport { IconButton } from \"./core/IconButton\";\nimport { InlineCode } from \"./core/InlineCode\";\n\nexport const CookieDropdown = memo(function CookieDropdown() {\n  const activeCookieJar = useActiveCookieJar();\n  const createCookieJar = useCreateCookieJar();\n  const cookieJars = useAtomValue(cookieJarsAtom);\n\n  const items = useMemo((): DropdownItem[] => {\n    return [\n      ...(cookieJars ?? []).map((j) => ({\n        key: j.id,\n        label: j.name,\n        leftSlot: <Icon icon={j.id === activeCookieJar?.id ? \"check\" : \"empty\"} />,\n        onSelect: () => {\n          setWorkspaceSearchParams({ cookie_jar_id: j.id });\n        },\n      })),\n      ...(((cookieJars ?? []).length > 0 && activeCookieJar != null\n        ? [\n            { type: \"separator\", label: activeCookieJar.name },\n            {\n              key: \"manage\",\n              label: \"Manage Cookies\",\n              leftSlot: <Icon icon=\"cookie\" />,\n              onSelect: () => {\n                if (activeCookieJar == null) return;\n                showDialog({\n                  id: \"cookies\",\n                  title: \"Manage Cookies\",\n                  size: \"full\",\n                  render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,\n                });\n              },\n            },\n            {\n              key: \"rename\",\n              label: \"Rename\",\n              leftSlot: <Icon icon=\"pencil\" />,\n              onSelect: async () => {\n                const name = await showPrompt({\n                  id: \"rename-cookie-jar\",\n                  title: \"Rename Cookie Jar\",\n                  description: (\n                    <>\n                      Enter a new name for <InlineCode>{activeCookieJar?.name}</InlineCode>\n                    </>\n                  ),\n                  label: \"Name\",\n                  confirmText: \"Save\",\n                  placeholder: \"New name\",\n                  defaultValue: activeCookieJar?.name,\n                });\n                if (name == null) return;\n                await patchModel(activeCookieJar, { name });\n              },\n            },\n            ...(((cookieJars ?? []).length > 1 // Never delete the last one\n              ? [\n                  {\n                    label: \"Delete\",\n                    leftSlot: <Icon icon=\"trash\" />,\n                    color: \"danger\",\n                    onSelect: async () => {\n                      await deleteModelWithConfirm(activeCookieJar);\n                    },\n                  },\n                ]\n              : []) as DropdownItem[]),\n          ]\n        : []) as DropdownItem[]),\n      { type: \"separator\" },\n      {\n        key: \"create-cookie-jar\",\n        label: \"New Cookie Jar\",\n        leftSlot: <Icon icon=\"plus\" />,\n        onSelect: () => createCookieJar.mutate(),\n      },\n    ];\n  }, [activeCookieJar, cookieJars, createCookieJar]);\n\n  return (\n    <Dropdown items={items}>\n      <IconButton size=\"sm\" icon=\"cookie\" iconColor=\"secondary\" title=\"Cookie Jar\" />\n    </Dropdown>\n  );\n});\n"
  },
  {
    "path": "src-web/components/CopyButton.tsx",
    "content": "import { useTimedBoolean } from \"../hooks/useTimedBoolean\";\nimport { copyToClipboard } from \"../lib/copy\";\nimport { showToast } from \"../lib/toast\";\nimport type { ButtonProps } from \"./core/Button\";\nimport { Button } from \"./core/Button\";\n\ninterface Props extends Omit<ButtonProps, \"onClick\"> {\n  text: string | (() => Promise<string | null>);\n}\n\nexport function CopyButton({ text, ...props }: Props) {\n  const [copied, setCopied] = useTimedBoolean();\n  return (\n    <Button\n      {...props}\n      onClick={async () => {\n        const content = typeof text === \"function\" ? await text() : text;\n        if (content == null) {\n          showToast({\n            id: \"failed-to-copy\",\n            color: \"danger\",\n            message: \"Failed to copy\",\n          });\n        } else {\n          copyToClipboard(content, { disableToast: true });\n          setCopied();\n        }\n      }}\n    >\n      {copied ? \"Copied\" : \"Copy\"}\n    </Button>\n  );\n}\n"
  },
  {
    "path": "src-web/components/CopyIconButton.tsx",
    "content": "import { useTimedBoolean } from \"../hooks/useTimedBoolean\";\nimport { copyToClipboard } from \"../lib/copy\";\nimport { showToast } from \"../lib/toast\";\nimport type { IconButtonProps } from \"./core/IconButton\";\nimport { IconButton } from \"./core/IconButton\";\n\ninterface Props extends Omit<IconButtonProps, \"onClick\" | \"icon\"> {\n  text: string | (() => Promise<string | null>);\n}\n\nexport function CopyIconButton({ text, ...props }: Props) {\n  const [copied, setCopied] = useTimedBoolean();\n  return (\n    <IconButton\n      {...props}\n      icon={copied ? \"check\" : \"copy\"}\n      showConfirm\n      onClick={async () => {\n        const content = typeof text === \"function\" ? await text() : text;\n        if (content == null) {\n          showToast({\n            id: \"failed-to-copy\",\n            color: \"danger\",\n            message: \"Failed to copy\",\n          });\n        } else {\n          copyToClipboard(content, { disableToast: true });\n          setCopied();\n        }\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/CreateDropdown.tsx",
    "content": "import { useCreateDropdownItems } from \"../hooks/useCreateDropdownItems\";\nimport type { DropdownProps } from \"./core/Dropdown\";\nimport { Dropdown } from \"./core/Dropdown\";\n\ninterface Props extends Omit<DropdownProps, \"items\"> {\n  hideFolder?: boolean;\n}\n\nexport function CreateDropdown({ hideFolder, children, ...props }: Props) {\n  const getItems = useCreateDropdownItems({\n    hideFolder,\n    hideIcons: true,\n    folderId: \"active-folder\",\n  });\n\n  return (\n    <Dropdown items={getItems} {...props}>\n      {children}\n    </Dropdown>\n  );\n}\n"
  },
  {
    "path": "src-web/components/CreateEnvironmentDialog.tsx",
    "content": "import { createWorkspaceModel } from \"@yaakapp-internal/models\";\nimport { useState } from \"react\";\nimport { useToggle } from \"../hooks/useToggle\";\nimport { ColorIndicator } from \"./ColorIndicator\";\nimport { Button } from \"./core/Button\";\nimport { Checkbox } from \"./core/Checkbox\";\nimport { ColorPickerWithThemeColors } from \"./core/ColorPicker\";\nimport { Label } from \"./core/Label\";\nimport { PlainInput } from \"./core/PlainInput\";\n\ninterface Props {\n  onCreate: (id: string) => void;\n  hide: () => void;\n  workspaceId: string;\n}\n\nexport function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props) {\n  const [name, setName] = useState<string>(\"\");\n  const [color, setColor] = useState<string | null>(null);\n  const [sharable, toggleSharable] = useToggle(false);\n  return (\n    <form\n      className=\"pb-3 flex flex-col gap-3\"\n      onSubmit={async (e) => {\n        e.preventDefault();\n        const id = await createWorkspaceModel({\n          model: \"environment\",\n          name,\n          color,\n          variables: [],\n          public: sharable,\n          workspaceId,\n          parentModel: \"environment\",\n        });\n        hide();\n        onCreate(id);\n      }}\n    >\n      <PlainInput\n        label=\"Name\"\n        required\n        defaultValue={name}\n        onChange={setName}\n        placeholder=\"Production\"\n      />\n      <Checkbox\n        checked={sharable}\n        title=\"Share this environment\"\n        help=\"Sharable environments are included in data export and directory sync.\"\n        onChange={toggleSharable}\n      />\n      <div>\n        <Label\n          htmlFor=\"color\"\n          className=\"mb-1.5\"\n          help=\"Select a color to be displayed when this environment is active, to help identify it.\"\n        >\n          Color\n        </Label>\n        <ColorPickerWithThemeColors onChange={setColor} color={color} />\n      </div>\n      <Button type=\"submit\" color=\"secondary\" className=\"mt-3\">\n        {color != null && <ColorIndicator color={color} />}\n        Create Environment\n      </Button>\n    </form>\n  );\n}\n"
  },
  {
    "path": "src-web/components/CreateWorkspaceDialog.tsx",
    "content": "import { gitMutations } from \"@yaakapp-internal/git\";\nimport type { WorkspaceMeta } from \"@yaakapp-internal/models\";\nimport { createGlobalModel, updateModel } from \"@yaakapp-internal/models\";\nimport { useState } from \"react\";\nimport { router } from \"../lib/router\";\nimport { setupOrConfigureEncryption } from \"../lib/setupOrConfigureEncryption\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { showErrorToast } from \"../lib/toast\";\nimport { Button } from \"./core/Button\";\nimport { Checkbox } from \"./core/Checkbox\";\nimport { Label } from \"./core/Label\";\nimport { PlainInput } from \"./core/PlainInput\";\nimport { VStack } from \"./core/Stacks\";\nimport { EncryptionHelp } from \"./EncryptionHelp\";\nimport { gitCallbacks } from \"./git/callbacks\";\nimport { SyncToFilesystemSetting } from \"./SyncToFilesystemSetting\";\n\ninterface Props {\n  hide: () => void;\n}\n\nexport function CreateWorkspaceDialog({ hide }: Props) {\n  const [name, setName] = useState<string>(\"\");\n  const [syncConfig, setSyncConfig] = useState<{\n    filePath: string | null;\n    initGit?: boolean;\n  }>({ filePath: null, initGit: false });\n  const [setupEncryption, setSetupEncryption] = useState<boolean>(false);\n  return (\n    <VStack\n      as=\"form\"\n      space={3}\n      alignItems=\"start\"\n      className=\"pb-3\"\n      onSubmit={async (e) => {\n        e.preventDefault();\n        const workspaceId = await createGlobalModel({ model: \"workspace\", name });\n        if (workspaceId == null) return;\n\n        // Do getWorkspaceMeta instead of naively creating one because it might have\n        // been created already when the store refreshes the workspace meta after\n        const workspaceMeta = await invokeCmd<WorkspaceMeta>(\"cmd_get_workspace_meta\", {\n          workspaceId,\n        });\n        await updateModel({\n          ...workspaceMeta,\n          settingSyncDir: syncConfig.filePath,\n        });\n\n        if (syncConfig.initGit && syncConfig.filePath) {\n          gitMutations(syncConfig.filePath, gitCallbacks(syncConfig.filePath))\n            .init.mutateAsync()\n            .catch((err) => {\n              showErrorToast({\n                id: \"git-init-error\",\n                title: \"Error initializing Git\",\n                message: String(err),\n              });\n            });\n        }\n\n        // Navigate to workspace\n        await router.navigate({\n          to: \"/workspaces/$workspaceId\",\n          params: { workspaceId },\n        });\n\n        hide();\n\n        if (setupEncryption) {\n          setupOrConfigureEncryption();\n        }\n      }}\n    >\n      <PlainInput required label=\"Name\" defaultValue={name} onChange={setName} />\n\n      <SyncToFilesystemSetting\n        onChange={setSyncConfig}\n        onCreateNewWorkspace={hide}\n        value={syncConfig}\n      />\n      <div>\n        <Label htmlFor={null} help={<EncryptionHelp />}>\n          Workspace encryption\n        </Label>\n        <Checkbox\n          checked={setupEncryption}\n          onChange={setSetupEncryption}\n          title=\"Enable Encryption\"\n        />\n      </div>\n      <Button type=\"submit\" color=\"primary\" className=\"w-full mt-3\">\n        Create Workspace\n      </Button>\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/Dialogs.tsx",
    "content": "import { useAtomValue } from \"jotai\";\nimport type { ComponentType } from \"react\";\nimport { useCallback } from \"react\";\nimport { dialogsAtom, hideDialog } from \"../lib/dialog\";\nimport { Dialog, type DialogProps } from \"./core/Dialog\";\nimport { ErrorBoundary } from \"./ErrorBoundary\";\n\nexport type DialogInstance = {\n  id: string;\n  render: ComponentType<{ hide: () => void }>;\n} & Omit<DialogProps, \"open\" | \"children\">;\n\nexport function Dialogs() {\n  const dialogs = useAtomValue(dialogsAtom);\n  return (\n    <>\n      {dialogs.map(({ id, ...props }) => (\n        <DialogInstance key={id} id={id} {...props} />\n      ))}\n    </>\n  );\n}\n\nfunction DialogInstance({ render: Component, onClose, id, ...props }: DialogInstance) {\n  const hide = useCallback(() => {\n    hideDialog(id);\n  }, [id]);\n\n  const handleClose = useCallback(() => {\n    onClose?.();\n    hideDialog(id);\n  }, [id, onClose]);\n\n  return (\n    <Dialog open onClose={handleClose} {...props}>\n      <ErrorBoundary name={`Dialog ${id}`}>\n        <Component hide={hide} {...props} />\n      </ErrorBoundary>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "src-web/components/DnsOverridesEditor.tsx",
    "content": "import type { DnsOverride, Workspace } from \"@yaakapp-internal/models\";\nimport { patchModel } from \"@yaakapp-internal/models\";\nimport { useCallback, useId, useMemo } from \"react\";\nimport { fireAndForget } from \"../lib/fireAndForget\";\nimport { Button } from \"./core/Button\";\nimport { Checkbox } from \"./core/Checkbox\";\nimport { IconButton } from \"./core/IconButton\";\nimport { PlainInput } from \"./core/PlainInput\";\nimport { HStack, VStack } from \"./core/Stacks\";\nimport { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from \"./core/Table\";\n\ninterface Props {\n  workspace: Workspace;\n}\n\ninterface DnsOverrideWithId extends DnsOverride {\n  _id: string;\n}\n\nexport function DnsOverridesEditor({ workspace }: Props) {\n  const reactId = useId();\n\n  // Ensure each override has an internal ID for React keys\n  const overridesWithIds = useMemo<DnsOverrideWithId[]>(() => {\n    return workspace.settingDnsOverrides.map((override, index) => ({\n      ...override,\n      _id: `${reactId}-${index}`,\n    }));\n  }, [workspace.settingDnsOverrides, reactId]);\n\n  const handleChange = useCallback(\n    (overrides: DnsOverride[]) => {\n      fireAndForget(patchModel(workspace, { settingDnsOverrides: overrides }));\n    },\n    [workspace],\n  );\n\n  const handleAdd = useCallback(() => {\n    const newOverride: DnsOverride = {\n      hostname: \"\",\n      ipv4: [\"\"],\n      ipv6: [],\n      enabled: true,\n    };\n    handleChange([...workspace.settingDnsOverrides, newOverride]);\n  }, [workspace.settingDnsOverrides, handleChange]);\n\n  const handleUpdate = useCallback(\n    (index: number, update: Partial<DnsOverride>) => {\n      const updated = workspace.settingDnsOverrides.map((o, i) =>\n        i === index ? { ...o, ...update } : o,\n      );\n      handleChange(updated);\n    },\n    [workspace.settingDnsOverrides, handleChange],\n  );\n\n  const handleDelete = useCallback(\n    (index: number) => {\n      const updated = workspace.settingDnsOverrides.filter((_, i) => i !== index);\n      handleChange(updated);\n    },\n    [workspace.settingDnsOverrides, handleChange],\n  );\n\n  return (\n    <VStack space={3} className=\"pb-3\">\n      <div className=\"text-text-subtle text-sm\">\n        Override DNS resolution for specific hostnames. This works like{\" \"}\n        <code className=\"text-text-subtlest bg-surface-highlight px-1 rounded\">/etc/hosts</code> but\n        only for requests made from this workspace.\n      </div>\n\n      {overridesWithIds.length > 0 && (\n        <Table>\n          <TableHead>\n            <TableRow>\n              <TableHeaderCell className=\"w-8\" />\n              <TableHeaderCell>Hostname</TableHeaderCell>\n              <TableHeaderCell>IPv4 Address</TableHeaderCell>\n              <TableHeaderCell>IPv6 Address</TableHeaderCell>\n              <TableHeaderCell className=\"w-10\" />\n            </TableRow>\n          </TableHead>\n          <TableBody>\n            {overridesWithIds.map((override, index) => (\n              <DnsOverrideRow\n                key={override._id}\n                override={override}\n                onUpdate={(update) => handleUpdate(index, update)}\n                onDelete={() => handleDelete(index)}\n              />\n            ))}\n          </TableBody>\n        </Table>\n      )}\n\n      <HStack>\n        <Button size=\"xs\" color=\"secondary\" variant=\"border\" onClick={handleAdd}>\n          Add DNS Override\n        </Button>\n      </HStack>\n    </VStack>\n  );\n}\n\ninterface DnsOverrideRowProps {\n  override: DnsOverride;\n  onUpdate: (update: Partial<DnsOverride>) => void;\n  onDelete: () => void;\n}\n\nfunction DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {\n  const ipv4Value = override.ipv4.join(\", \");\n  const ipv6Value = override.ipv6.join(\", \");\n\n  return (\n    <TableRow>\n      <TableCell>\n        <Checkbox\n          hideLabel\n          title={override.enabled ? \"Disable override\" : \"Enable override\"}\n          checked={override.enabled ?? true}\n          onChange={(enabled) => onUpdate({ enabled })}\n        />\n      </TableCell>\n      <TableCell>\n        <PlainInput\n          size=\"sm\"\n          hideLabel\n          label=\"Hostname\"\n          placeholder=\"api.example.com\"\n          defaultValue={override.hostname}\n          onChange={(hostname) => onUpdate({ hostname })}\n        />\n      </TableCell>\n      <TableCell>\n        <PlainInput\n          size=\"sm\"\n          hideLabel\n          label=\"IPv4 addresses\"\n          placeholder=\"127.0.0.1\"\n          defaultValue={ipv4Value}\n          onChange={(value) =>\n            onUpdate({\n              ipv4: value\n                .split(\",\")\n                .map((s) => s.trim())\n                .filter(Boolean),\n            })\n          }\n        />\n      </TableCell>\n      <TableCell>\n        <PlainInput\n          size=\"sm\"\n          hideLabel\n          label=\"IPv6 addresses\"\n          placeholder=\"::1\"\n          defaultValue={ipv6Value}\n          onChange={(value) =>\n            onUpdate({\n              ipv6: value\n                .split(\",\")\n                .map((s) => s.trim())\n                .filter(Boolean),\n            })\n          }\n        />\n      </TableCell>\n      <TableCell>\n        <IconButton\n          size=\"xs\"\n          iconSize=\"sm\"\n          icon=\"trash\"\n          title=\"Delete override\"\n          onClick={onDelete}\n        />\n      </TableCell>\n    </TableRow>\n  );\n}\n"
  },
  {
    "path": "src-web/components/DropMarker.tsx",
    "content": "import classNames from \"classnames\";\nimport type { CSSProperties } from \"react\";\nimport { memo } from \"react\";\n\ninterface Props {\n  className?: string;\n  style?: CSSProperties;\n  orientation?: \"horizontal\" | \"vertical\";\n}\n\nexport const DropMarker = memo(\n  function DropMarker({ className, style, orientation = \"horizontal\" }: Props) {\n    return (\n      <div\n        style={style}\n        className={classNames(\n          className,\n          \"absolute pointer-events-none z-50\",\n          orientation === \"horizontal\" && \"w-full\",\n          orientation === \"vertical\" && \"w-0 top-0 bottom-0\",\n        )}\n      >\n        <div\n          className={classNames(\n            \"absolute bg-primary rounded-full\",\n            orientation === \"horizontal\" && \"left-2 right-2 -bottom-[0.1rem] h-[0.2rem]\",\n            orientation === \"vertical\" && \"-left-[0.1rem] top-0 bottom-0 w-[0.2rem]\",\n          )}\n        />\n      </div>\n    );\n  },\n  () => true,\n);\n"
  },
  {
    "path": "src-web/components/DynamicForm.tsx",
    "content": "import type { Folder, HttpRequest } from \"@yaakapp-internal/models\";\nimport { foldersAtom, httpRequestsAtom } from \"@yaakapp-internal/models\";\nimport type {\n  FormInput,\n  FormInputCheckbox,\n  FormInputEditor,\n  FormInputFile,\n  FormInputHttpRequest,\n  FormInputKeyValue,\n  FormInputSelect,\n  FormInputText,\n  JsonPrimitive,\n} from \"@yaakapp-internal/plugins\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport { useCallback, useEffect, useMemo } from \"react\";\nimport { useActiveRequest } from \"../hooks/useActiveRequest\";\nimport { useRandomKey } from \"../hooks/useRandomKey\";\nimport { capitalize } from \"../lib/capitalize\";\nimport { showDialog } from \"../lib/dialog\";\nimport { resolvedModelName } from \"../lib/resolvedModelName\";\nimport { Banner } from \"./core/Banner\";\nimport { Checkbox } from \"./core/Checkbox\";\nimport { DetailsBanner } from \"./core/DetailsBanner\";\nimport { Editor } from \"./core/Editor/LazyEditor\";\nimport { IconButton } from \"./core/IconButton\";\nimport type { InputProps } from \"./core/Input\";\nimport { Input } from \"./core/Input\";\nimport { Label } from \"./core/Label\";\nimport type { Pair } from \"./core/PairEditor\";\nimport { PairEditor } from \"./core/PairEditor\";\nimport { PlainInput } from \"./core/PlainInput\";\nimport { Select } from \"./core/Select\";\nimport { VStack } from \"./core/Stacks\";\nimport { Markdown } from \"./Markdown\";\nimport { SelectFile } from \"./SelectFile\";\n\nexport const DYNAMIC_FORM_NULL_ARG = \"__NULL__\";\nconst INPUT_SIZE = \"sm\";\n\ninterface Props<T> {\n  inputs: FormInput[] | undefined | null;\n  onChange: (value: T) => void;\n  data: T;\n  autocompleteFunctions?: boolean;\n  autocompleteVariables?: boolean;\n  stateKey: string;\n  className?: string;\n  disabled?: boolean;\n}\n\nexport function DynamicForm<T extends Record<string, JsonPrimitive>>({\n  inputs,\n  data,\n  onChange,\n  autocompleteVariables,\n  autocompleteFunctions,\n  stateKey,\n  className,\n  disabled,\n}: Props<T>) {\n  const setDataAttr = useCallback(\n    (name: string, value: JsonPrimitive) => {\n      onChange({ ...data, [name]: value === DYNAMIC_FORM_NULL_ARG ? undefined : value });\n    },\n    [data, onChange],\n  );\n\n  return (\n    <FormInputsStack\n      disabled={disabled}\n      inputs={inputs}\n      setDataAttr={setDataAttr}\n      stateKey={stateKey}\n      autocompleteFunctions={autocompleteFunctions}\n      autocompleteVariables={autocompleteVariables}\n      data={data}\n      className={classNames(className, \"pb-4\")} // Pad the bottom to look nice\n    />\n  );\n}\n\nfunction FormInputsStack<T extends Record<string, JsonPrimitive>>({\n  className,\n  ...props\n}: FormInputsProps<T> & { className?: string }) {\n  return (\n    <VStack\n      space={3}\n      className={classNames(\n        className,\n        \"h-full overflow-auto\",\n        \"pr-1\", // A bit of space between inputs and scrollbar\n      )}\n    >\n      <FormInputs {...props} />\n    </VStack>\n  );\n}\n\ntype FormInputsProps<T> = Pick<\n  Props<T>,\n  \"inputs\" | \"autocompleteFunctions\" | \"autocompleteVariables\" | \"stateKey\" | \"data\"\n> & {\n  setDataAttr: (name: string, value: JsonPrimitive) => void;\n  disabled?: boolean;\n};\n\nfunction FormInputs<T extends Record<string, JsonPrimitive>>({\n  inputs,\n  autocompleteFunctions,\n  autocompleteVariables,\n  stateKey,\n  setDataAttr,\n  data,\n  disabled,\n}: FormInputsProps<T>) {\n  return (\n    <>\n      {inputs?.map((input, i) => {\n        if (\"hidden\" in input && input.hidden) {\n          return null;\n        }\n\n        if (\"disabled\" in input && disabled != null) {\n          input.disabled = disabled;\n        }\n\n        switch (input.type) {\n          case \"select\":\n            return (\n              <SelectArg\n                key={i + stateKey}\n                arg={input}\n                onChange={(v) => setDataAttr(input.name, v)}\n                value={\n                  data[input.name]\n                    ? String(data[input.name])\n                    : (input.defaultValue ?? DYNAMIC_FORM_NULL_ARG)\n                }\n              />\n            );\n          case \"text\":\n            return (\n              <TextArg\n                key={i + stateKey}\n                stateKey={stateKey}\n                arg={input}\n                autocompleteFunctions={autocompleteFunctions || false}\n                autocompleteVariables={autocompleteVariables || false}\n                onChange={(v) => setDataAttr(input.name, v)}\n                value={\n                  data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? \"\")\n                }\n              />\n            );\n          case \"editor\":\n            return (\n              <EditorArg\n                key={i + stateKey}\n                stateKey={stateKey}\n                arg={input}\n                autocompleteFunctions={autocompleteFunctions || false}\n                autocompleteVariables={autocompleteVariables || false}\n                onChange={(v) => setDataAttr(input.name, v)}\n                value={\n                  data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? \"\")\n                }\n              />\n            );\n          case \"checkbox\":\n            return (\n              <CheckboxArg\n                key={i + stateKey}\n                arg={input}\n                onChange={(v) => setDataAttr(input.name, v)}\n                value={data[input.name] != null ? data[input.name] === true : false}\n              />\n            );\n          case \"http_request\":\n            return (\n              <HttpRequestArg\n                key={i + stateKey}\n                arg={input}\n                onChange={(v) => setDataAttr(input.name, v)}\n                value={data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG}\n              />\n            );\n          case \"file\":\n            return (\n              <FileArg\n                key={i + stateKey}\n                arg={input}\n                onChange={(v) => setDataAttr(input.name, v)}\n                filePath={\n                  data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG\n                }\n              />\n            );\n          case \"accordion\":\n            if (!hasVisibleInputs(input.inputs)) {\n              return null;\n            }\n            return (\n              <div key={i + stateKey}>\n                <DetailsBanner\n                  summary={input.label}\n                  className={classNames(\"!mb-auto\", disabled && \"opacity-disabled\")}\n                >\n                  <div className=\"mt-3\">\n                    <FormInputsStack\n                      data={data}\n                      disabled={disabled}\n                      inputs={input.inputs}\n                      setDataAttr={setDataAttr}\n                      stateKey={stateKey}\n                      autocompleteFunctions={autocompleteFunctions || false}\n                      autocompleteVariables={autocompleteVariables}\n                    />\n                  </div>\n                </DetailsBanner>\n              </div>\n            );\n          case \"h_stack\":\n            if (!hasVisibleInputs(input.inputs)) {\n              return null;\n            }\n            return (\n              <div className=\"flex flex-wrap sm:flex-nowrap gap-3 items-end\" key={i + stateKey}>\n                <FormInputs\n                  data={data}\n                  disabled={disabled}\n                  inputs={input.inputs}\n                  setDataAttr={setDataAttr}\n                  stateKey={stateKey}\n                  autocompleteFunctions={autocompleteFunctions || false}\n                  autocompleteVariables={autocompleteVariables}\n                />\n              </div>\n            );\n          case \"banner\":\n            if (!hasVisibleInputs(input.inputs)) {\n              return null;\n            }\n            return (\n              <Banner\n                key={i + stateKey}\n                color={input.color}\n                className={classNames(disabled && \"opacity-disabled\")}\n              >\n                <FormInputsStack\n                  data={data}\n                  disabled={disabled}\n                  inputs={input.inputs}\n                  setDataAttr={setDataAttr}\n                  stateKey={stateKey}\n                  autocompleteFunctions={autocompleteFunctions || false}\n                  autocompleteVariables={autocompleteVariables}\n                />\n              </Banner>\n            );\n          case \"markdown\":\n            return <Markdown key={i + stateKey}>{input.content}</Markdown>;\n          case \"key_value\":\n            return (\n              <KeyValueArg\n                key={i + stateKey}\n                arg={input}\n                stateKey={stateKey}\n                onChange={(v) => setDataAttr(input.name, v)}\n                value={\n                  data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? \"[]\")\n                }\n              />\n            );\n          default:\n            // @ts-expect-error\n            throw new Error(`Invalid input type: ${input.type}`);\n        }\n      })}\n    </>\n  );\n}\n\nfunction TextArg({\n  arg,\n  onChange,\n  value,\n  autocompleteFunctions,\n  autocompleteVariables,\n  stateKey,\n}: {\n  arg: FormInputText;\n  value: string;\n  onChange: (v: string) => void;\n  autocompleteFunctions: boolean;\n  autocompleteVariables: boolean;\n  stateKey: string;\n}) {\n  const props: InputProps = {\n    onChange,\n    name: arg.name,\n    multiLine: arg.multiLine,\n    className: arg.multiLine ? \"min-h-[4rem]\" : undefined,\n    defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value,\n    required: !arg.optional,\n    disabled: arg.disabled,\n    help: arg.description,\n    type: arg.password ? \"password\" : \"text\",\n    label: arg.label ?? arg.name,\n    size: INPUT_SIZE,\n    hideLabel: arg.hideLabel ?? arg.label == null,\n    placeholder: arg.placeholder ?? undefined,\n    forceUpdateKey: stateKey,\n    autocomplete: arg.completionOptions ? { options: arg.completionOptions } : undefined,\n    stateKey,\n    autocompleteFunctions,\n    autocompleteVariables,\n  };\n  if (autocompleteVariables || autocompleteFunctions || arg.completionOptions) {\n    return <Input {...props} />;\n  }\n  return <PlainInput {...props} />;\n}\n\nfunction EditorArg({\n  arg,\n  onChange,\n  value,\n  autocompleteFunctions,\n  autocompleteVariables,\n  stateKey,\n}: {\n  arg: FormInputEditor;\n  value: string;\n  onChange: (v: string) => void;\n  autocompleteFunctions: boolean;\n  autocompleteVariables: boolean;\n  stateKey: string;\n}) {\n  const id = `input-${arg.name}`;\n\n  // Read-only editor force refresh for every defaultValue change\n  // Should this be built into the <Editor/> component?\n  const [popoutKey, regeneratePopoutKey] = useRandomKey();\n  const forceUpdateKey = popoutKey + (arg.readOnly ? arg.defaultValue + stateKey : stateKey);\n\n  return (\n    <div className=\"w-full grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)]\">\n      <Label\n        htmlFor={id}\n        required={!arg.optional}\n        visuallyHidden={arg.hideLabel}\n        help={arg.description}\n        tags={arg.language ? [capitalize(arg.language)] : undefined}\n      >\n        {arg.label}\n      </Label>\n      <div\n        className={classNames(\n          \"border border-border rounded-md overflow-hidden px-2 py-1\",\n          \"focus-within:border-border-focus\",\n          !arg.rows && \"max-h-[10rem]\", // So it doesn't take up too much space\n        )}\n        style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}\n      >\n        <Editor\n          id={id}\n          autocomplete={arg.completionOptions ? { options: arg.completionOptions } : undefined}\n          disabled={arg.disabled}\n          language={arg.language}\n          readOnly={arg.readOnly}\n          onChange={onChange}\n          hideGutter\n          heightMode=\"auto\"\n          className=\"min-h-[3rem]\"\n          defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}\n          placeholder={arg.placeholder ?? undefined}\n          autocompleteFunctions={autocompleteFunctions}\n          autocompleteVariables={autocompleteVariables}\n          stateKey={stateKey}\n          forceUpdateKey={forceUpdateKey}\n          actions={\n            <div>\n              <IconButton\n                variant=\"border\"\n                size=\"sm\"\n                className=\"my-0.5 opacity-60 group-hover:opacity-100\"\n                icon=\"expand\"\n                title=\"Pop out to large editor\"\n                onClick={() => {\n                  showDialog({\n                    id: \"id\",\n                    size: \"full\",\n                    title: arg.readOnly ? \"View Value\" : \"Edit Value\",\n                    className: \"!max-w-[50rem] !max-h-[60rem]\",\n                    description: arg.label && (\n                      <Label\n                        htmlFor={id}\n                        required={!arg.optional}\n                        visuallyHidden={arg.hideLabel}\n                        help={arg.description}\n                        tags={arg.language ? [capitalize(arg.language)] : undefined}\n                      >\n                        {arg.label}\n                      </Label>\n                    ),\n                    onClose() {\n                      // Force the main editor to update on close\n                      regeneratePopoutKey();\n                    },\n                    render() {\n                      return (\n                        <Editor\n                          id={id}\n                          autocomplete={\n                            arg.completionOptions ? { options: arg.completionOptions } : undefined\n                          }\n                          disabled={arg.disabled}\n                          language={arg.language}\n                          readOnly={arg.readOnly}\n                          onChange={onChange}\n                          defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}\n                          placeholder={arg.placeholder ?? undefined}\n                          autocompleteFunctions={autocompleteFunctions}\n                          autocompleteVariables={autocompleteVariables}\n                          stateKey={stateKey}\n                          forceUpdateKey={forceUpdateKey}\n                        />\n                      );\n                    },\n                  });\n                }}\n              />\n            </div>\n          }\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction SelectArg({\n  arg,\n  value,\n  onChange,\n}: {\n  arg: FormInputSelect;\n  value: string;\n  onChange: (v: string) => void;\n}) {\n  return (\n    <Select\n      label={arg.label ?? arg.name}\n      name={arg.name}\n      help={arg.description}\n      onChange={onChange}\n      defaultValue={arg.defaultValue}\n      hideLabel={arg.hideLabel}\n      value={value}\n      size={INPUT_SIZE}\n      disabled={arg.disabled}\n      options={arg.options}\n    />\n  );\n}\n\nfunction FileArg({\n  arg,\n  filePath,\n  onChange,\n}: {\n  arg: FormInputFile;\n  filePath: string;\n  onChange: (v: string | null) => void;\n}) {\n  return (\n    <SelectFile\n      disabled={arg.disabled}\n      help={arg.description}\n      onChange={({ filePath }) => onChange(filePath)}\n      filePath={filePath === DYNAMIC_FORM_NULL_ARG ? null : filePath}\n      directory={!!arg.directory}\n    />\n  );\n}\n\nfunction HttpRequestArg({\n  arg,\n  value,\n  onChange,\n}: {\n  arg: FormInputHttpRequest;\n  value: string;\n  onChange: (v: string) => void;\n}) {\n  const folders = useAtomValue(foldersAtom);\n  const httpRequests = useAtomValue(httpRequestsAtom);\n  const activeHttpRequest = useActiveRequest(\"http_request\");\n\n  useEffect(() => {\n    if (value === DYNAMIC_FORM_NULL_ARG && activeHttpRequest) {\n      onChange(activeHttpRequest.id);\n    }\n  }, [activeHttpRequest, onChange, value]);\n\n  return (\n    <Select\n      label={arg.label ?? arg.name}\n      name={arg.name}\n      onChange={onChange}\n      help={arg.description}\n      value={value}\n      disabled={arg.disabled}\n      options={httpRequests.map((r) => {\n        return {\n          label:\n            buildRequestBreadcrumbs(r, folders).join(\" / \") +\n            (r.id === activeHttpRequest?.id ? \" (current)\" : \"\"),\n          value: r.id,\n        };\n      })}\n    />\n  );\n}\n\nfunction buildRequestBreadcrumbs(request: HttpRequest, folders: Folder[]): string[] {\n  const ancestors: (HttpRequest | Folder)[] = [request];\n\n  const next = () => {\n    const latest = ancestors[0];\n    if (latest == null) return [];\n\n    const parent = folders.find((f) => f.id === latest.folderId);\n    if (parent == null) return;\n\n    ancestors.unshift(parent);\n    next();\n  };\n  next();\n\n  return ancestors.map((a) => (a.model === \"folder\" ? a.name : resolvedModelName(a)));\n}\n\nfunction CheckboxArg({\n  arg,\n  onChange,\n  value,\n}: {\n  arg: FormInputCheckbox;\n  value: boolean;\n  onChange: (v: boolean) => void;\n}) {\n  return (\n    <Checkbox\n      onChange={onChange}\n      checked={value}\n      help={arg.description}\n      disabled={arg.disabled}\n      title={arg.label ?? arg.name}\n      hideLabel={arg.label == null}\n    />\n  );\n}\n\nfunction KeyValueArg({\n  arg,\n  onChange,\n  value,\n  stateKey,\n}: {\n  arg: FormInputKeyValue;\n  value: string;\n  onChange: (v: string) => void;\n  stateKey: string;\n}) {\n  const pairs: Pair[] = useMemo(() => {\n    try {\n      const parsed = JSON.parse(value);\n      return Array.isArray(parsed) ? parsed : [];\n    } catch {\n      return [];\n    }\n  }, [value]);\n\n  const handleChange = useCallback(\n    (newPairs: Pair[]) => {\n      onChange(JSON.stringify(newPairs));\n    },\n    [onChange],\n  );\n\n  return (\n    <div className=\"w-full grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden\">\n      <Label\n        htmlFor={`input-${arg.name}`}\n        required={!arg.optional}\n        visuallyHidden={arg.hideLabel}\n        help={arg.description}\n      >\n        {arg.label ?? arg.name}\n      </Label>\n      <PairEditor\n        pairs={pairs}\n        onChange={handleChange}\n        stateKey={stateKey}\n        namePlaceholder=\"name\"\n        valuePlaceholder=\"value\"\n        noScroll\n      />\n    </div>\n  );\n}\n\nfunction hasVisibleInputs(inputs: FormInput[] | undefined): boolean {\n  if (!inputs) return false;\n\n  for (const input of inputs) {\n    if (\"inputs\" in input && !hasVisibleInputs(input.inputs)) {\n      // Has children, but none are visible\n      return false;\n    }\n    if (!input.hidden) {\n      return true;\n    }\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "src-web/components/EmptyStateText.tsx",
    "content": "import classNames from \"classnames\";\nimport type { ReactNode } from \"react\";\n\ninterface Props {\n  children: ReactNode;\n  className?: string;\n}\n\nexport function EmptyStateText({ children, className }: Props) {\n  return (\n    <div className=\"w-full h-full pb-2\">\n      <div\n        className={classNames(\n          className,\n          \"rounded-lg border border-dashed border-border-subtle\",\n          \"h-full py-2 text-text-subtlest flex items-center justify-center italic\",\n        )}\n      >\n        {children}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/EncryptionHelp.tsx",
    "content": "import { VStack } from \"./core/Stacks\";\n\nexport function EncryptionHelp() {\n  return (\n    <VStack space={3}>\n      <p>Encrypt passwords, tokens, and other sensitive info when encryption is enabled.</p>\n      <p>\n        Encrypted data remains secure when syncing to the filesystem or Git, and when exporting or\n        sharing with others.\n      </p>\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/EnvironmentActionsDropdown.tsx",
    "content": "import classNames from \"classnames\";\nimport { memo, useMemo } from \"react\";\nimport { useActiveEnvironment } from \"../hooks/useActiveEnvironment\";\nimport { useEnvironmentsBreakdown } from \"../hooks/useEnvironmentsBreakdown\";\nimport { editEnvironment } from \"../lib/editEnvironment\";\nimport { setWorkspaceSearchParams } from \"../lib/setWorkspaceSearchParams\";\nimport type { ButtonProps } from \"./core/Button\";\nimport { Button } from \"./core/Button\";\nimport type { DropdownItem } from \"./core/Dropdown\";\nimport { Dropdown } from \"./core/Dropdown\";\nimport { Icon } from \"./core/Icon\";\nimport { EnvironmentColorIndicator } from \"./EnvironmentColorIndicator\";\n\ntype Props = {\n  className?: string;\n} & Pick<ButtonProps, \"forDropdown\" | \"leftSlot\">;\n\nexport const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdown({\n  className,\n  ...buttonProps\n}: Props) {\n  const { subEnvironments, baseEnvironment } = useEnvironmentsBreakdown();\n  const activeEnvironment = useActiveEnvironment();\n\n  const items: DropdownItem[] = useMemo(\n    () => [\n      ...subEnvironments.map(\n        (e) => ({\n          key: e.id,\n          label: e.name,\n          rightSlot: <EnvironmentColorIndicator environment={e} />,\n          leftSlot: e.id === activeEnvironment?.id ? <Icon icon=\"check\" /> : <Icon icon=\"empty\" />,\n          onSelect: async () => {\n            if (e.id !== activeEnvironment?.id) {\n              setWorkspaceSearchParams({ environment_id: e.id });\n            } else {\n              setWorkspaceSearchParams({ environment_id: null });\n            }\n          },\n        }),\n        [activeEnvironment?.id],\n      ),\n      ...((subEnvironments.length > 0\n        ? [{ type: \"separator\", label: \"Environments\" }]\n        : []) as DropdownItem[]),\n      {\n        label: \"Manage Environments\",\n        hotKeyAction: \"environment_editor.toggle\",\n        leftSlot: <Icon icon=\"box\" />,\n        onSelect: () => editEnvironment(activeEnvironment),\n      },\n    ],\n    [subEnvironments, activeEnvironment],\n  );\n\n  const hasBaseVars =\n    (baseEnvironment?.variables ?? []).filter((v) => v.enabled && (v.name || v.value)).length > 0;\n\n  return (\n    <Dropdown items={items}>\n      <Button\n        size=\"sm\"\n        className={classNames(\n          className,\n          \"text !px-2 truncate\",\n          !activeEnvironment && !hasBaseVars && \"text-text-subtlest italic\",\n        )}\n        // If no environments, the button simply opens the dialog.\n        // NOTE: We don't create a new button because we want to reuse the hotkey from the menu items\n        onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}\n        {...buttonProps}\n      >\n        <EnvironmentColorIndicator environment={activeEnvironment ?? null} />\n        {activeEnvironment?.name ?? (hasBaseVars ? \"Environment\" : \"No Environment\")}\n      </Button>\n    </Dropdown>\n  );\n});\n"
  },
  {
    "path": "src-web/components/EnvironmentColorIndicator.tsx",
    "content": "import type { Environment } from \"@yaakapp-internal/models\";\nimport { showColorPicker } from \"../lib/showColorPicker\";\nimport { ColorIndicator } from \"./ColorIndicator\";\n\nexport function EnvironmentColorIndicator({\n  environment,\n  clickToEdit,\n  className,\n}: {\n  environment: Environment | null;\n  clickToEdit?: boolean;\n  className?: string;\n}) {\n  if (environment?.color == null) return null;\n\n  return (\n    <ColorIndicator\n      className={className}\n      color={environment?.color ?? null}\n      onClick={clickToEdit ? () => showColorPicker(environment) : undefined}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/EnvironmentColorPicker.tsx",
    "content": "import { useState } from \"react\";\nimport { ColorIndicator } from \"./ColorIndicator\";\nimport { Banner } from \"./core/Banner\";\nimport { Button } from \"./core/Button\";\nimport { ColorPickerWithThemeColors } from \"./core/ColorPicker\";\n\nexport function EnvironmentColorPicker({\n  color: defaultColor,\n  onChange,\n}: {\n  color: string | null;\n  onChange: (color: string | null) => void;\n}) {\n  const [color, setColor] = useState<string | null>(defaultColor);\n  return (\n    <form\n      className=\"flex flex-col items-stretch gap-5 pb-2 w-full\"\n      onSubmit={(e) => {\n        e.preventDefault();\n        onChange(color);\n      }}\n    >\n      <Banner color=\"secondary\">\n        This color will be used to color the interface when this environment is active\n      </Banner>\n      <ColorPickerWithThemeColors color={color} onChange={setColor} />\n      <Button type=\"submit\" color=\"secondary\">\n        {color != null && <ColorIndicator color={color} />}\n        Save\n      </Button>\n    </form>\n  );\n}\n"
  },
  {
    "path": "src-web/components/EnvironmentEditDialog.tsx",
    "content": "import type { Environment, Workspace } from \"@yaakapp-internal/models\";\nimport { duplicateModel, patchModel } from \"@yaakapp-internal/models\";\nimport { atom, useAtomValue } from \"jotai\";\nimport { useCallback, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { createSubEnvironmentAndActivate } from \"../commands/createEnvironment\";\nimport { activeWorkspaceAtom, activeWorkspaceIdAtom } from \"../hooks/useActiveWorkspace\";\nimport {\n  environmentsBreakdownAtom,\n  useEnvironmentsBreakdown,\n} from \"../hooks/useEnvironmentsBreakdown\";\nimport { deleteModelWithConfirm } from \"../lib/deleteModelWithConfirm\";\nimport { fireAndForget } from \"../lib/fireAndForget\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { isBaseEnvironment, isSubEnvironment } from \"../lib/model_util\";\nimport { resolvedModelName } from \"../lib/resolvedModelName\";\nimport { showColorPicker } from \"../lib/showColorPicker\";\nimport { Banner } from \"./core/Banner\";\nimport type { ContextMenuProps, DropdownItem } from \"./core/Dropdown\";\nimport { Icon } from \"./core/Icon\";\nimport { IconButton } from \"./core/IconButton\";\nimport { IconTooltip } from \"./core/IconTooltip\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport type { PairEditorHandle } from \"./core/PairEditor\";\nimport { SplitLayout } from \"./core/SplitLayout\";\nimport type { TreeNode } from \"./core/tree/common\";\nimport type { TreeHandle, TreeProps } from \"./core/tree/Tree\";\nimport { Tree } from \"./core/tree/Tree\";\nimport { EnvironmentColorIndicator } from \"./EnvironmentColorIndicator\";\nimport { EnvironmentEditor } from \"./EnvironmentEditor\";\nimport { EnvironmentSharableTooltip } from \"./EnvironmentSharableTooltip\";\n\ninterface Props {\n  initialEnvironmentId: string | null;\n  setRef?: (ref: PairEditorHandle | null) => void;\n}\n\ntype TreeModel = Environment | Workspace;\n\nexport function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {\n  const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();\n  const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(\n    initialEnvironmentId ?? null,\n  );\n\n  const selectedEnvironment =\n    selectedEnvironmentId != null\n      ? allEnvironments.find((e) => e.id === selectedEnvironmentId)\n      : baseEnvironment;\n\n  return (\n    <SplitLayout\n      name=\"env_editor\"\n      defaultRatio={0.75}\n      layout=\"horizontal\"\n      className=\"gap-0\"\n      resizeHandleClassName=\"-translate-x-[1px]\"\n      firstSlot={() => (\n        <EnvironmentEditDialogSidebar\n          selectedEnvironmentId={selectedEnvironment?.id ?? null}\n          setSelectedEnvironmentId={setSelectedEnvironmentId}\n        />\n      )}\n      secondSlot={() => (\n        <div className=\"grid grid-rows-[auto_minmax(0,1fr)]\">\n          {baseEnvironments.length > 1 ? (\n            <div className=\"p-3\">\n              <Banner color=\"notice\">\n                There are multiple base environments for this workspace. Please delete the\n                environments you no longer need.\n              </Banner>\n            </div>\n          ) : (\n            <span />\n          )}\n          {selectedEnvironment == null ? (\n            <div className=\"p-3 mt-10\">\n              <Banner color=\"danger\">\n                Failed to find selected environment <InlineCode>{selectedEnvironmentId}</InlineCode>\n              </Banner>\n            </div>\n          ) : (\n            <EnvironmentEditor\n              key={selectedEnvironment.id}\n              setRef={setRef}\n              className=\"pl-4 pt-3\"\n              environment={selectedEnvironment}\n            />\n          )}\n        </div>\n      )}\n    />\n  );\n}\n\nconst sharableTooltip = (\n  <IconTooltip\n    tabIndex={-1}\n    icon=\"eye\"\n    iconSize=\"sm\"\n    content=\"This environment will be included in Directory Sync and data exports\"\n  />\n);\n\nfunction EnvironmentEditDialogSidebar({\n  selectedEnvironmentId,\n  setSelectedEnvironmentId,\n}: {\n  selectedEnvironmentId: string | null;\n  setSelectedEnvironmentId: (id: string | null) => void;\n}) {\n  const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? \"\";\n  const treeId = `environment.${activeWorkspaceId}.sidebar`;\n  const treeRef = useRef<TreeHandle>(null);\n  const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();\n\n  // oxlint-disable-next-line react-hooks/exhaustive-deps\n  useLayoutEffect(() => {\n    if (selectedEnvironmentId == null) return;\n    treeRef.current?.selectItem(selectedEnvironmentId);\n    treeRef.current?.focus();\n  }, []);\n\n  const handleDeleteEnvironment = useCallback(\n    async (environment: Environment) => {\n      await deleteModelWithConfirm(environment);\n      if (selectedEnvironmentId === environment.id) {\n        setSelectedEnvironmentId(baseEnvironment?.id ?? null);\n      }\n    },\n    [baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],\n  );\n\n  const actions = useMemo(() => {\n    const enable = () => treeRef.current?.hasFocus() ?? false;\n\n    const actions = {\n      \"sidebar.selected.rename\": {\n        enable,\n        allowDefault: true,\n        priority: 100,\n        cb: async (items: TreeModel[]) => {\n          const item = items[0];\n          if (items.length === 1 && item != null) {\n            treeRef.current?.renameItem(item.id);\n          }\n        },\n      },\n      \"sidebar.selected.delete\": {\n        priority: 100,\n        enable,\n        cb: (items: TreeModel[]) => deleteModelWithConfirm(items),\n      },\n      \"sidebar.selected.duplicate\": {\n        priority: 100,\n        enable,\n        cb: async (items: TreeModel[]) => {\n          if (items.length === 1 && items[0]) {\n            const item = items[0];\n            const newId = await duplicateModel(item);\n            setSelectedEnvironmentId(newId);\n          } else {\n            await Promise.all(items.map(duplicateModel));\n          }\n        },\n      },\n    } as const;\n    return actions;\n  }, [setSelectedEnvironmentId]);\n\n  const hotkeys = useMemo<TreeProps<TreeModel>[\"hotkeys\"]>(() => ({ actions }), [actions]);\n\n  const getContextMenu = useCallback(\n    (items: TreeModel[]): ContextMenuProps[\"items\"] => {\n      const environment = items[0];\n      const addEnvironmentItem: DropdownItem = {\n        label: \"Create Sub Environment\",\n        leftSlot: <Icon icon=\"plus\" />,\n        onSelect: async () => {\n          await createSubEnvironment();\n        },\n      };\n\n      if (environment == null || environment.model !== \"environment\") {\n        return [addEnvironmentItem];\n      }\n\n      const singleEnvironment = items.length === 1;\n      const canDeleteEnvironment =\n        isSubEnvironment(environment) ||\n        (isBaseEnvironment(environment) && baseEnvironments.length > 1);\n\n      const menuItems: DropdownItem[] = [\n        {\n          label: \"Rename\",\n          leftSlot: <Icon icon=\"pencil\" />,\n          hidden: isBaseEnvironment(environment) || !singleEnvironment,\n          hotKeyAction: \"sidebar.selected.rename\",\n          hotKeyLabelOnly: true,\n          onSelect: async () => {\n            // Not sure why this is needed, but without it the\n            // edit input blurs immediately after opening.\n            requestAnimationFrame(() => {\n              fireAndForget(actions[\"sidebar.selected.rename\"].cb(items));\n            });\n          },\n        },\n        {\n          label: \"Duplicate\",\n          leftSlot: <Icon icon=\"copy\" />,\n          hidden: isBaseEnvironment(environment),\n          hotKeyAction: \"sidebar.selected.duplicate\",\n          hotKeyLabelOnly: true,\n          onSelect: () => actions[\"sidebar.selected.duplicate\"].cb(items),\n        },\n        {\n          label: environment.color ? \"Change Color\" : \"Assign Color\",\n          leftSlot: <Icon icon=\"palette\" />,\n          hidden: isBaseEnvironment(environment) || !singleEnvironment,\n          onSelect: async () => showColorPicker(environment),\n        },\n        {\n          label: `Make ${environment.public ? \"Private\" : \"Sharable\"}`,\n          leftSlot: <Icon icon={environment.public ? \"eye_closed\" : \"eye\"} />,\n          rightSlot: <EnvironmentSharableTooltip />,\n          hidden: items.length > 1,\n          onSelect: async () => {\n            await patchModel(environment, { public: !environment.public });\n          },\n        },\n        {\n          color: \"danger\",\n          label: \"Delete\",\n          hotKeyAction: \"sidebar.selected.delete\",\n          hotKeyLabelOnly: true,\n          hidden: !canDeleteEnvironment,\n          leftSlot: <Icon icon=\"trash\" />,\n          onSelect: () => handleDeleteEnvironment(environment),\n        },\n      ];\n\n      // Add sub environment to base environment\n      if (isBaseEnvironment(environment) && singleEnvironment) {\n        menuItems.push({ type: \"separator\" });\n        menuItems.push(addEnvironmentItem);\n      }\n\n      return menuItems;\n    },\n    [actions, baseEnvironments.length, handleDeleteEnvironment],\n  );\n\n  const handleDragEnd = useCallback(async function handleDragEnd({\n    items,\n    children,\n    insertAt,\n  }: {\n    items: TreeModel[];\n    children: TreeModel[];\n    insertAt: number;\n  }) {\n    const prev = children[insertAt - 1] as Exclude<TreeModel, Workspace>;\n    const next = children[insertAt] as Exclude<TreeModel, Workspace>;\n\n    const beforePriority = prev?.sortPriority ?? 0;\n    const afterPriority = next?.sortPriority ?? 0;\n    const shouldUpdateAll = afterPriority - beforePriority < 1;\n\n    try {\n      if (shouldUpdateAll) {\n        // Add items to children at insertAt\n        children.splice(insertAt, 0, ...items);\n        await Promise.all(children.map((m, i) => patchModel(m, { sortPriority: i * 1000 })));\n      } else {\n        const range = afterPriority - beforePriority;\n        const increment = range / (items.length + 2);\n        await Promise.all(\n          items.map((m, i) => {\n            const sortPriority = beforePriority + (i + 1) * increment;\n            // Spread item sortPriority out over before/after range\n            return patchModel(m, { sortPriority });\n          }),\n        );\n      }\n    } catch (e) {\n      console.error(e);\n    }\n  }, []);\n\n  const handleActivate = useCallback(\n    (item: TreeModel) => {\n      setSelectedEnvironmentId(item.id);\n    },\n    [setSelectedEnvironmentId],\n  );\n\n  const tree = useAtomValue(treeAtom);\n  return (\n    <aside className=\"x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle \">\n      {tree != null && (\n        <div className=\"pt-2\">\n          <Tree\n            ref={treeRef}\n            treeId={treeId}\n            className=\"px-2 pb-10\"\n            hotkeys={hotkeys}\n            root={tree}\n            getContextMenu={getContextMenu}\n            onDragEnd={handleDragEnd}\n            getItemKey={(i) => `${i.id}::${i.name}`}\n            ItemLeftSlotInner={ItemLeftSlotInner}\n            ItemRightSlot={ItemRightSlot}\n            ItemInner={ItemInner}\n            onActivate={handleActivate}\n            getEditOptions={getEditOptions}\n          />\n        </div>\n      )}\n    </aside>\n  );\n}\n\nconst treeAtom = atom<TreeNode<TreeModel> | null>((get) => {\n  const activeWorkspace = get(activeWorkspaceAtom);\n  const { baseEnvironment, baseEnvironments, subEnvironments } = get(environmentsBreakdownAtom);\n  if (activeWorkspace == null || baseEnvironment == null) return null;\n\n  const root: TreeNode<TreeModel> = {\n    item: activeWorkspace,\n    parent: null,\n    children: [],\n    depth: 0,\n  };\n\n  for (const item of baseEnvironments) {\n    root.children?.push({\n      item,\n      parent: root,\n      depth: 0,\n      draggable: false,\n    });\n  }\n\n  const parent = root.children?.[0];\n  if (baseEnvironments.length <= 1 && parent != null) {\n    parent.children = subEnvironments.map((item) => ({\n      item,\n      parent,\n      depth: 1,\n      localDrag: true,\n    }));\n  }\n\n  return root;\n});\n\nfunction ItemLeftSlotInner({ item }: { item: TreeModel }) {\n  const { baseEnvironments } = useEnvironmentsBreakdown();\n  return baseEnvironments.length > 1 ? (\n    <Icon icon=\"alert_triangle\" color=\"notice\" />\n  ) : (\n    item.model === \"environment\" && item.color && <EnvironmentColorIndicator environment={item} />\n  );\n}\n\nfunction ItemRightSlot({ item }: { item: TreeModel }) {\n  const { baseEnvironments } = useEnvironmentsBreakdown();\n  return (\n    <>\n      {item.model === \"environment\" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (\n        <IconButton\n          size=\"sm\"\n          color=\"custom\"\n          iconSize=\"sm\"\n          icon=\"plus_circle\"\n          className=\"opacity-50 hover:opacity-100\"\n          title=\"Add Sub-Environment\"\n          onClick={createSubEnvironment}\n        />\n      )}\n    </>\n  );\n}\n\nfunction ItemInner({ item }: { item: TreeModel }) {\n  return (\n    <div className=\"grid grid-cols-[auto_minmax(0,1fr)] w-full items-center\">\n      {item.model === \"environment\" && item.public ? (\n        <div className=\"mr-2 flex items-center\">{sharableTooltip}</div>\n      ) : (\n        <span aria-hidden />\n      )}\n      <div className=\"truncate min-w-0 text-left\">{resolvedModelName(item)}</div>\n    </div>\n  );\n}\n\nasync function createSubEnvironment() {\n  const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);\n  if (baseEnvironment == null) return;\n  const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);\n  return id;\n}\n\nfunction getEditOptions(item: TreeModel) {\n  const options: ReturnType<NonNullable<TreeProps<TreeModel>[\"getEditOptions\"]>> = {\n    defaultValue: item.name,\n    placeholder: \"Name\",\n    async onChange(item, name) {\n      await patchModel(item, { name });\n    },\n  };\n  return options;\n}\n"
  },
  {
    "path": "src-web/components/EnvironmentEditor.tsx",
    "content": "import type { Environment } from \"@yaakapp-internal/models\";\nimport { patchModel } from \"@yaakapp-internal/models\";\nimport type { GenericCompletionOption } from \"@yaakapp-internal/plugins\";\nimport classNames from \"classnames\";\nimport { useCallback, useMemo } from \"react\";\nimport { useEnvironmentsBreakdown } from \"../hooks/useEnvironmentsBreakdown\";\nimport { useIsEncryptionEnabled } from \"../hooks/useIsEncryptionEnabled\";\nimport { useKeyValue } from \"../hooks/useKeyValue\";\nimport { useRandomKey } from \"../hooks/useRandomKey\";\nimport { analyzeTemplate, convertTemplateToSecure } from \"../lib/encryption\";\nimport { isBaseEnvironment } from \"../lib/model_util\";\nimport {\n  setupOrConfigureEncryption,\n  withEncryptionEnabled,\n} from \"../lib/setupOrConfigureEncryption\";\nimport { DismissibleBanner } from \"./core/DismissibleBanner\";\nimport type { GenericCompletionConfig } from \"./core/Editor/genericCompletion\";\nimport { Heading } from \"./core/Heading\";\nimport type { PairEditorHandle, PairWithId } from \"./core/PairEditor\";\nimport { ensurePairId } from \"./core/PairEditor.util\";\nimport { PairOrBulkEditor } from \"./core/PairOrBulkEditor\";\nimport { PillButton } from \"./core/PillButton\";\nimport { EnvironmentColorIndicator } from \"./EnvironmentColorIndicator\";\nimport { EnvironmentSharableTooltip } from \"./EnvironmentSharableTooltip\";\n\ninterface Props {\n  environment: Environment;\n  hideName?: boolean;\n  className?: string;\n  setRef?: (n: PairEditorHandle | null) => void;\n}\n\nexport function EnvironmentEditor({ environment, hideName, className, setRef }: Props) {\n  const workspaceId = environment.workspaceId;\n  const isEncryptionEnabled = useIsEncryptionEnabled();\n  const valueVisibility = useKeyValue<boolean>({\n    namespace: \"global\",\n    key: [\"environmentValueVisibility\", workspaceId],\n    fallback: false,\n  });\n  const { allEnvironments } = useEnvironmentsBreakdown();\n  const handleChange = useCallback(\n    (variables: PairWithId[]) => patchModel(environment, { variables }),\n    [environment],\n  );\n  const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey();\n\n  // Gather a list of env names from other environments to help the user get them aligned\n  const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {\n    const options: GenericCompletionOption[] = [];\n    if (isBaseEnvironment(environment)) {\n      return { options };\n    }\n\n    const allVariables = allEnvironments.flatMap((e) => e?.variables);\n    const allVariableNames = new Set(allVariables.map((v) => v?.name));\n    for (const name of allVariableNames) {\n      const containingEnvs = allEnvironments.filter((e) =>\n        e.variables.some((v) => v.name === name),\n      );\n      const isAlreadyInActive = containingEnvs.find((e) => e.id === environment.id);\n      if (isAlreadyInActive) {\n        continue;\n      }\n      options.push({\n        label: name,\n        type: \"constant\",\n        detail: containingEnvs.map((e) => e.name).join(\", \"),\n      });\n    }\n    return { options };\n  }, [environment, allEnvironments]);\n\n  const validateName = useCallback((name: string) => {\n    // Empty just means the variable doesn't have a name yet and is unusable\n    if (name === \"\") return true;\n    return name.match(/^[a-z_][a-z0-9_.-]*$/i) != null;\n  }, []);\n\n  const valueType = !isEncryptionEnabled && valueVisibility.value ? \"text\" : \"password\";\n  const allVariableAreEncrypted = useMemo(\n    () =>\n      environment.variables.every((v) => v.value === \"\" || analyzeTemplate(v.value) !== \"insecure\"),\n    [environment.variables],\n  );\n\n  const encryptEnvironment = (environment: Environment) => {\n    withEncryptionEnabled(async () => {\n      const encryptedVariables: PairWithId[] = [];\n      for (const variable of environment.variables) {\n        const value = variable.value ? await convertTemplateToSecure(variable.value) : \"\";\n        encryptedVariables.push(ensurePairId({ ...variable, value }));\n      }\n      await handleChange(encryptedVariables);\n      regenerateForceUpdateKey();\n    });\n  };\n\n  return (\n    <div\n      className={classNames(\n        className,\n        \"h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3\",\n      )}\n    >\n      <div className=\"flex flex-col gap-4\">\n        <Heading className=\"w-full flex items-center gap-0.5\">\n          <EnvironmentColorIndicator\n            className=\"mr-2\"\n            clickToEdit\n            environment={environment ?? null}\n          />\n          {!hideName && <div className=\"mr-2\">{environment?.name}</div>}\n          {isEncryptionEnabled ? (\n            !allVariableAreEncrypted ? (\n              <PillButton color=\"notice\" onClick={() => encryptEnvironment(environment)}>\n                Encrypt All Variables\n              </PillButton>\n            ) : (\n              <PillButton color=\"secondary\" onClick={setupOrConfigureEncryption}>\n                Encryption Settings\n              </PillButton>\n            )\n          ) : (\n            <PillButton color=\"secondary\" onClick={() => valueVisibility.set((v) => !v)}>\n              {valueVisibility.value ? \"Hide Values\" : \"Show Values\"}\n            </PillButton>\n          )}\n          <PillButton\n            color=\"secondary\"\n            rightSlot={<EnvironmentSharableTooltip />}\n            onClick={async () => {\n              await patchModel(environment, { public: !environment.public });\n            }}\n          >\n            {environment.public ? \"Sharable\" : \"Private\"}\n          </PillButton>\n        </Heading>\n        {environment.public && (!isEncryptionEnabled || !allVariableAreEncrypted) && (\n          <DismissibleBanner\n            id={`warn-unencrypted-${environment.id}`}\n            color=\"notice\"\n            className=\"mr-3\"\n            actions={[\n              {\n                label: \"Encrypt Variables\",\n                onClick: () => encryptEnvironment(environment),\n                color: \"success\",\n              },\n            ]}\n          >\n            This sharable environment contains plain-text secrets\n          </DismissibleBanner>\n        )}\n      </div>\n      <PairOrBulkEditor\n        setRef={setRef}\n        className=\"h-full\"\n        allowMultilineValues\n        preferenceName=\"environment\"\n        nameAutocomplete={nameAutocomplete}\n        namePlaceholder=\"VAR_NAME\"\n        nameValidate={validateName}\n        valueType={valueType}\n        valueAutocompleteVariables=\"environment\"\n        valueAutocompleteFunctions\n        forceUpdateKey={`${environment.id}::${forceUpdateKey}`}\n        pairs={environment.variables}\n        onChange={handleChange}\n        stateKey={`environment.${environment.id}`}\n        forcedEnvironmentId={environment.id}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/EnvironmentSharableTooltip.tsx",
    "content": "import { IconTooltip } from \"./core/IconTooltip\";\n\nexport function EnvironmentSharableTooltip() {\n  return (\n    <IconTooltip content=\"Sharable environments are included in Directory Sync and data export.\" />\n  );\n}\n"
  },
  {
    "path": "src-web/components/ErrorBoundary.tsx",
    "content": "import type { ErrorInfo, ReactNode } from \"react\";\nimport { Component, useEffect } from \"react\";\nimport { showDialog } from \"../lib/dialog\";\nimport { Banner } from \"./core/Banner\";\nimport { Button } from \"./core/Button\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport RouteError from \"./RouteError\";\n\ninterface ErrorBoundaryProps {\n  name: string;\n  children: ReactNode;\n}\n\ninterface ErrorBoundaryState {\n  hasError: boolean;\n  error: Error | null;\n}\n\nexport class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {\n  constructor(props: ErrorBoundaryProps) {\n    super(props);\n    this.state = { hasError: false, error: null };\n  }\n\n  static getDerivedStateFromError(error: Error): ErrorBoundaryState {\n    return { hasError: true, error };\n  }\n\n  componentDidCatch(error: Error, info: ErrorInfo) {\n    console.warn(\"Error caught by ErrorBoundary:\", error, info);\n  }\n\n  render() {\n    if (this.state.hasError) {\n      return (\n        <Banner color=\"danger\" className=\"flex items-center gap-2 overflow-auto\">\n          <div>\n            Error rendering <InlineCode>{this.props.name}</InlineCode> component\n          </div>\n          <Button\n            className=\"inline-flex\"\n            variant=\"border\"\n            color=\"danger\"\n            size=\"2xs\"\n            onClick={() => {\n              showDialog({\n                id: \"error-boundary\",\n                render: () => <RouteError error={this.state.error} />,\n              });\n            }}\n          >\n            Show\n          </Button>\n        </Banner>\n      );\n    }\n\n    return this.props.children;\n  }\n}\n\nexport function ErrorBoundaryTestThrow() {\n  useEffect(() => {\n    throw new Error(\"test error\");\n  });\n\n  return <div>Hello</div>;\n}\n"
  },
  {
    "path": "src-web/components/ExportDataDialog.tsx",
    "content": "import { save } from \"@tauri-apps/plugin-dialog\";\nimport type { Workspace } from \"@yaakapp-internal/models\";\nimport { workspacesAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport slugify from \"slugify\";\nimport { activeWorkspaceAtom } from \"../hooks/useActiveWorkspace\";\nimport { pluralizeCount } from \"../lib/pluralize\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { Button } from \"./core/Button\";\nimport { Checkbox } from \"./core/Checkbox\";\nimport { DetailsBanner } from \"./core/DetailsBanner\";\nimport { Link } from \"./core/Link\";\nimport { HStack, VStack } from \"./core/Stacks\";\n\ninterface Props {\n  onHide: () => void;\n  onSuccess: (path: string) => void;\n}\n\nexport function ExportDataDialog({ onHide, onSuccess }: Props) {\n  const allWorkspaces = useAtomValue(workspacesAtom);\n  const activeWorkspace = useAtomValue(activeWorkspaceAtom);\n  if (activeWorkspace == null || allWorkspaces.length === 0) return null;\n\n  return (\n    <ExportDataDialogContent\n      onHide={onHide}\n      onSuccess={onSuccess}\n      allWorkspaces={allWorkspaces}\n      activeWorkspace={activeWorkspace}\n    />\n  );\n}\n\nfunction ExportDataDialogContent({\n  onHide,\n  onSuccess,\n  activeWorkspace,\n  allWorkspaces,\n}: Props & {\n  allWorkspaces: Workspace[];\n  activeWorkspace: Workspace;\n}) {\n  const [includePrivateEnvironments, setIncludePrivateEnvironments] = useState<boolean>(false);\n  const [selectedWorkspaces, setSelectedWorkspaces] = useState<Record<string, boolean>>({\n    [activeWorkspace.id]: true,\n  });\n\n  // Put the active workspace first\n  const workspaces = useMemo(\n    () => [activeWorkspace, ...allWorkspaces.filter((w) => w.id !== activeWorkspace.id)],\n    [activeWorkspace, allWorkspaces],\n  );\n\n  const handleToggleAll = () => {\n    setSelectedWorkspaces(\n      // oxlint-disable-next-line no-accumulating-spread\n      allSelected ? {} : workspaces.reduce((acc, w) => ({ ...acc, [w.id]: true }), {}),\n    );\n  };\n\n  const handleExport = useCallback(async () => {\n    const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);\n    const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;\n    const slug = workspace ? slugify(workspace.name, { lower: true }) : \"workspaces\";\n    const exportPath = await save({\n      title: \"Export Data\",\n      defaultPath: `yaak.${slug}.json`,\n    });\n    if (exportPath == null) {\n      return;\n    }\n\n    await invokeCmd(\"cmd_export_data\", {\n      workspaceIds: ids,\n      exportPath,\n      includePrivateEnvironments: includePrivateEnvironments,\n    });\n    onHide();\n    onSuccess(exportPath);\n  }, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);\n\n  const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);\n  const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;\n  const noneSelected = numSelected === 0;\n  return (\n    <div className=\"w-full grid grid-rows-[minmax(0,1fr)_auto]\">\n      <VStack space={3} className=\"overflow-auto px-5 pb-6\">\n        <table className=\"w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight\">\n          <thead>\n            <tr>\n              <th className=\"w-6 min-w-0 py-2 text-left pl-1\">\n                <Checkbox\n                  checked={!allSelected && !noneSelected ? \"indeterminate\" : allSelected}\n                  hideLabel\n                  title=\"All workspaces\"\n                  onChange={handleToggleAll}\n                />\n              </th>\n              <th className=\"py-2 text-left pl-4\" onClick={handleToggleAll}>\n                Workspace\n              </th>\n            </tr>\n          </thead>\n          <tbody className=\"divide-y divide-surface-highlight\">\n            {workspaces.map((w) => (\n              <tr key={w.id}>\n                <td className=\"min-w-0 py-1 pl-1\">\n                  <Checkbox\n                    checked={selectedWorkspaces[w.id] ?? false}\n                    title={w.name}\n                    hideLabel\n                    onChange={() =>\n                      setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))\n                    }\n                  />\n                </td>\n                <td\n                  className=\"py-1 pl-4 text whitespace-nowrap overflow-x-auto hide-scrollbars\"\n                  onClick={() =>\n                    setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))\n                  }\n                >\n                  {w.name} {w.id === activeWorkspace.id ? \"(current workspace)\" : \"\"}\n                </td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n        <DetailsBanner color=\"secondary\" defaultOpen summary=\"Extra Settings\">\n          <Checkbox\n            checked={includePrivateEnvironments}\n            onChange={setIncludePrivateEnvironments}\n            title=\"Include private environments\"\n            help='Environments marked as \"sharable\" will be exported by default'\n          />\n        </DetailsBanner>\n      </VStack>\n      <footer className=\"px-5 grid grid-cols-[1fr_auto] items-center bg-surface-highlight py-2 border-t border-border-subtle\">\n        <div>\n          <Link href=\"https://yaak.app/button/new\" noUnderline className=\"text-text-subtle\">\n            Create Run Button\n          </Link>\n        </div>\n        <HStack space={2} justifyContent=\"end\">\n          <Button size=\"sm\" className=\"focus\" variant=\"border\" onClick={onHide}>\n            Cancel\n          </Button>\n          <Button\n            size=\"sm\"\n            type=\"submit\"\n            className=\"focus\"\n            color=\"primary\"\n            disabled={noneSelected}\n            onClick={() => handleExport()}\n          >\n            Export{\" \"}\n            {pluralizeCount(\"Workspace\", numSelected, { omitSingle: true, noneWord: \"Nothing\" })}\n          </Button>\n        </HStack>\n      </footer>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/FolderLayout.tsx",
    "content": "import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from \"@yaakapp-internal/models\";\nimport { foldersAtom } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { useCallback, useMemo } from \"react\";\nimport { allRequestsAtom } from \"../hooks/useAllRequests\";\nimport { useFolderActions } from \"../hooks/useFolderActions\";\nimport { useLatestHttpResponse } from \"../hooks/useLatestHttpResponse\";\nimport { sendAnyHttpRequest } from \"../hooks/useSendAnyHttpRequest\";\nimport { fireAndForget } from \"../lib/fireAndForget\";\nimport { showDialog } from \"../lib/dialog\";\nimport { resolvedModelName } from \"../lib/resolvedModelName\";\nimport { router } from \"../lib/router\";\nimport { Button } from \"./core/Button\";\nimport { Heading } from \"./core/Heading\";\nimport { HttpResponseDurationTag } from \"./core/HttpResponseDurationTag\";\nimport { HttpStatusTag } from \"./core/HttpStatusTag\";\nimport { Icon } from \"./core/Icon\";\nimport { IconButton } from \"./core/IconButton\";\nimport { LoadingIcon } from \"./core/LoadingIcon\";\nimport { Separator } from \"./core/Separator\";\nimport { SizeTag } from \"./core/SizeTag\";\nimport { HStack } from \"./core/Stacks\";\nimport { HttpResponsePane } from \"./HttpResponsePane\";\n\ninterface Props {\n  folder: Folder;\n  style: CSSProperties;\n}\n\nexport function FolderLayout({ folder, style }: Props) {\n  const folders = useAtomValue(foldersAtom);\n  const requests = useAtomValue(allRequestsAtom);\n  const folderActions = useFolderActions();\n  const sendAllAction = useMemo(\n    () => folderActions.find((a) => a.label === \"Send All\"),\n    [folderActions],\n  );\n\n  const children = useMemo(() => {\n    return [\n      ...folders.filter((f) => f.folderId === folder.id),\n      ...requests.filter((r) => r.folderId === folder.id),\n    ];\n  }, [folder.id, folders, requests]);\n\n  const handleSendAll = useCallback(() => {\n    if (sendAllAction) fireAndForget(sendAllAction.call(folder));\n  }, [sendAllAction, folder]);\n\n  return (\n    <div style={style} className=\"p-6 pt-4 overflow-y-auto @container\">\n      <HStack space={2} alignItems=\"center\">\n        <Icon icon=\"folder\" size=\"xl\" color=\"secondary\" />\n        <Heading level={1}>{resolvedModelName(folder)}</Heading>\n        <HStack className=\"ml-auto\" alignItems=\"center\">\n          <Button\n            rightSlot={<Icon icon=\"send_horizontal\" />}\n            color=\"secondary\"\n            size=\"sm\"\n            variant=\"border\"\n            onClick={handleSendAll}\n            disabled={sendAllAction == null}\n          >\n            Send All\n          </Button>\n        </HStack>\n      </HStack>\n      <Separator className=\"mt-3 mb-8\" />\n      <div className=\"grid grid-cols-1 @lg:grid-cols-2 @4xl:grid-cols-3 gap-4 min-w-0\">\n        {children.map((child) => (\n          <ChildCard key={child.id} child={child} />\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | WebsocketRequest }) {\n  let card: ReactNode;\n  if (child.model === \"folder\") {\n    card = <FolderCard folder={child} />;\n  } else if (child.model === \"http_request\") {\n    card = <HttpRequestCard request={child} />;\n  } else if (child.model === \"grpc_request\") {\n    card = <RequestCard request={child} />;\n  } else if (child.model === \"websocket_request\") {\n    card = <RequestCard request={child} />;\n  } else {\n    card = <div>Unknown model</div>;\n  }\n\n  const navigate = useCallback(async () => {\n    await router.navigate({\n      to: \"/workspaces/$workspaceId\",\n      params: { workspaceId: child.workspaceId },\n      search: (prev) => ({ ...prev, request_id: child.id }),\n    });\n  }, [child.id, child.workspaceId]);\n\n  return (\n    <div\n      className={classNames(\n        \"rounded-lg bg-surface-highlight p-3 pt-1 border border-border\",\n        \"flex flex-col gap-3\",\n      )}\n    >\n      <HStack space={2}>\n        {child.model === \"folder\" && <Icon icon=\"folder\" size=\"lg\" />}\n        <Heading className=\"truncate\" level={2}>\n          {resolvedModelName(child)}\n        </Heading>\n        <HStack space={0.5} className=\"ml-auto -mr-1.5\">\n          <IconButton\n            color=\"custom\"\n            title=\"Send Request\"\n            size=\"sm\"\n            icon=\"external_link\"\n            className=\"opacity-70 hover:opacity-100\"\n            onClick={navigate}\n          />\n          <IconButton\n            color=\"custom\"\n            title=\"Send Request\"\n            size=\"sm\"\n            icon=\"send_horizontal\"\n            className=\"opacity-70 hover:opacity-100\"\n            onClick={() => {\n              sendAnyHttpRequest.mutate(child.id);\n            }}\n          />\n        </HStack>\n      </HStack>\n      <div className=\"text-text-subtle\">{card}</div>\n    </div>\n  );\n}\n\nfunction FolderCard({ folder }: { folder: Folder }) {\n  return (\n    <div>\n      <Button\n        color=\"primary\"\n        onClick={async () => {\n          await router.navigate({\n            to: \"/workspaces/$workspaceId\",\n            params: { workspaceId: folder.workspaceId },\n            search: (prev) => {\n              return { ...prev, request_id: null, folder_id: folder.id };\n            },\n          });\n        }}\n      >\n        Open\n      </Button>\n    </div>\n  );\n}\n\nfunction RequestCard({ request }: { request: HttpRequest | GrpcRequest | WebsocketRequest }) {\n  return <div>TODO {request.id}</div>;\n}\n\nfunction HttpRequestCard({ request }: { request: HttpRequest }) {\n  const latestResponse = useLatestHttpResponse(request.id);\n\n  return (\n    <div className=\"grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden\">\n      <code className=\"font-mono text-editor text-info border border-info rounded px-2.5 py-0.5 truncate w-full min-w-0\">\n        {request.method} {request.url}\n      </code>\n      {latestResponse ? (\n        <button\n          className=\"block mr-auto\"\n          type=\"button\"\n          tabIndex={-1}\n          onClick={(e) => {\n            e.stopPropagation();\n            showDialog({\n              id: \"response-preview\",\n              title: \"Response Preview\",\n              size: \"md\",\n              className: \"h-full\",\n              render: () => {\n                return <HttpResponsePane activeRequestId={request.id} />;\n              },\n            });\n          }}\n        >\n          <HStack\n            space={2}\n            alignItems=\"center\"\n            className={classNames(\n              \"cursor-default select-none\",\n              \"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars\",\n              \"font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full\",\n            )}\n          >\n            {latestResponse.state !== \"closed\" && <LoadingIcon size=\"sm\" />}\n            <HttpStatusTag showReason response={latestResponse} />\n            <span>&bull;</span>\n            <HttpResponseDurationTag response={latestResponse} />\n            <span>&bull;</span>\n            <SizeTag\n              contentLength={latestResponse.contentLength ?? 0}\n              contentLengthCompressed={latestResponse.contentLength}\n            />\n          </HStack>\n        </button>\n      ) : (\n        <div>No Responses</div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/FolderSettingsDialog.tsx",
    "content": "import { createWorkspaceModel, foldersAtom, patchModel } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { Fragment, useMemo } from \"react\";\nimport { useAuthTab } from \"../hooks/useAuthTab\";\nimport { useEnvironmentsBreakdown } from \"../hooks/useEnvironmentsBreakdown\";\nimport { useHeadersTab } from \"../hooks/useHeadersTab\";\nimport { useInheritedHeaders } from \"../hooks/useInheritedHeaders\";\nimport { useModelAncestors } from \"../hooks/useModelAncestors\";\nimport { deleteModelWithConfirm } from \"../lib/deleteModelWithConfirm\";\nimport { hideDialog } from \"../lib/dialog\";\nimport { CopyIconButton } from \"./CopyIconButton\";\nimport { Button } from \"./core/Button\";\nimport { CountBadge } from \"./core/CountBadge\";\nimport { Icon } from \"./core/Icon\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport { Input } from \"./core/Input\";\nimport { Link } from \"./core/Link\";\nimport { HStack, VStack } from \"./core/Stacks\";\nimport type { TabItem } from \"./core/Tabs/Tabs\";\nimport { TabContent, Tabs } from \"./core/Tabs/Tabs\";\nimport { EmptyStateText } from \"./EmptyStateText\";\nimport { EnvironmentEditor } from \"./EnvironmentEditor\";\nimport { HeadersEditor } from \"./HeadersEditor\";\nimport { HttpAuthenticationEditor } from \"./HttpAuthenticationEditor\";\nimport { MarkdownEditor } from \"./MarkdownEditor\";\n\ninterface Props {\n  folderId: string | null;\n  tab?: FolderSettingsTab;\n}\n\nconst TAB_AUTH = \"auth\";\nconst TAB_HEADERS = \"headers\";\nconst TAB_VARIABLES = \"variables\";\nconst TAB_GENERAL = \"general\";\n\nexport type FolderSettingsTab =\n  | typeof TAB_AUTH\n  | typeof TAB_HEADERS\n  | typeof TAB_GENERAL\n  | typeof TAB_VARIABLES;\n\nexport function FolderSettingsDialog({ folderId, tab }: Props) {\n  const folders = useAtomValue(foldersAtom);\n  const folder = folders.find((f) => f.id === folderId) ?? null;\n  const ancestors = useModelAncestors(folder);\n  const breadcrumbs = useMemo(() => ancestors.toReversed(), [ancestors]);\n  const authTab = useAuthTab(TAB_AUTH, folder);\n  const headersTab = useHeadersTab(TAB_HEADERS, folder);\n  const inheritedHeaders = useInheritedHeaders(folder);\n  const environments = useEnvironmentsBreakdown();\n  const folderEnvironment = environments.allEnvironments.find(\n    (e) => e.parentModel === \"folder\" && e.parentId === folderId,\n  );\n  const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;\n\n  const tabs = useMemo<TabItem[]>(() => {\n    if (folder == null) return [];\n\n    return [\n      {\n        value: TAB_GENERAL,\n        label: \"General\",\n      },\n      ...headersTab,\n      ...authTab,\n      {\n        value: TAB_VARIABLES,\n        label: \"Variables\",\n        rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,\n      },\n    ];\n  }, [authTab, folder, headersTab, numVars]);\n\n  if (folder == null) return null;\n\n  return (\n    <div className=\"h-full flex flex-col\">\n      <div className=\"flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl\">\n        <Icon icon=\"folder_cog\" size=\"lg\" color=\"secondary\" className=\"flex-shrink-0\" />\n        <div className=\"flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1\">\n          {breadcrumbs.map((item, index) => (\n            <Fragment key={item.id}>\n              {index > 0 && (\n                <Icon icon=\"chevron_right\" size=\"lg\" className=\"opacity-50 flex-shrink-0\" />\n              )}\n              <span className=\"text-text-subtle truncate min-w-0\" title={item.name}>\n                {item.name}\n              </span>\n            </Fragment>\n          ))}\n          {breadcrumbs.length > 0 && (\n            <Icon icon=\"chevron_right\" size=\"lg\" className=\"opacity-50 flex-shrink-0\" />\n          )}\n          <span className=\"whitespace-nowrap\" title={folder.name}>\n            {folder.name}\n          </span>\n        </div>\n      </div>\n\n      <Tabs\n        defaultValue={tab ?? TAB_GENERAL}\n        label=\"Folder Settings\"\n        className=\"pt-2 pb-2 pl-3 pr-1 flex-1\"\n        layout=\"horizontal\"\n        addBorders\n        tabs={tabs}\n      >\n        <TabContent value={TAB_AUTH} className=\"overflow-y-auto h-full px-4\">\n          <HttpAuthenticationEditor model={folder} />\n        </TabContent>\n        <TabContent value={TAB_GENERAL} className=\"overflow-y-auto h-full px-4\">\n          <div className=\"grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 pb-3 h-full\">\n            <Input\n              label=\"Folder Name\"\n              defaultValue={folder.name}\n              onChange={(name) => patchModel(folder, { name })}\n              stateKey={`name.${folder.id}`}\n            />\n            <MarkdownEditor\n              name=\"folder-description\"\n              placeholder=\"Folder description\"\n              className=\"border border-border px-2\"\n              defaultValue={folder.description}\n              stateKey={`description.${folder.id}`}\n              onChange={(description) => patchModel(folder, { description })}\n            />\n            <HStack alignItems=\"center\" justifyContent=\"between\" className=\"w-full\">\n              <Button\n                onClick={async () => {\n                  const didDelete = await deleteModelWithConfirm(folder);\n                  if (didDelete) {\n                    hideDialog(\"folder-settings\");\n                  }\n                }}\n                color=\"danger\"\n                variant=\"border\"\n                size=\"xs\"\n              >\n                Delete Folder\n              </Button>\n              <InlineCode className=\"flex gap-1 items-center text-primary pl-2.5\">\n                {folder.id}\n                <CopyIconButton\n                  className=\"opacity-70 !text-primary\"\n                  size=\"2xs\"\n                  iconSize=\"sm\"\n                  title=\"Copy folder ID\"\n                  text={folder.id}\n                />\n              </InlineCode>\n            </HStack>\n          </div>\n        </TabContent>\n        <TabContent value={TAB_HEADERS} className=\"overflow-y-auto h-full px-4\">\n          <HeadersEditor\n            inheritedHeaders={inheritedHeaders}\n            forceUpdateKey={folder.id}\n            headers={folder.headers}\n            onChange={(headers) => patchModel(folder, { headers })}\n            stateKey={`headers.${folder.id}`}\n          />\n        </TabContent>\n        <TabContent value={TAB_VARIABLES} className=\"overflow-y-auto h-full px-4\">\n          {folderEnvironment == null ? (\n            <EmptyStateText>\n              <VStack alignItems=\"center\" space={1.5}>\n                <p>\n                  Override{\" \"}\n                  <Link href=\"https://yaak.app/docs/using-yaak/environments-and-variables\">\n                    Variables\n                  </Link>{\" \"}\n                  for requests within this folder.\n                </p>\n                <Button\n                  variant=\"border\"\n                  size=\"sm\"\n                  onClick={async () => {\n                    await createWorkspaceModel({\n                      workspaceId: folder.workspaceId,\n                      parentModel: \"folder\",\n                      parentId: folder.id,\n                      model: \"environment\",\n                      name: \"Folder Environment\",\n                    });\n                  }}\n                >\n                  Create Folder Environment\n                </Button>\n              </VStack>\n            </EmptyStateText>\n          ) : (\n            <EnvironmentEditor hideName environment={folderEnvironment} />\n          )}\n        </TabContent>\n      </Tabs>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/FormMultipartEditor.tsx",
    "content": "import type { HttpRequest } from \"@yaakapp-internal/models\";\nimport { useCallback, useMemo } from \"react\";\nimport type { Pair, PairEditorProps } from \"./core/PairEditor\";\nimport { PairEditor } from \"./core/PairEditor\";\n\ntype Props = {\n  forceUpdateKey: string;\n  request: HttpRequest;\n  onChange: (body: HttpRequest[\"body\"]) => void;\n};\n\nexport function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props) {\n  const pairs = useMemo<Pair[]>(\n    () =>\n      (Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({\n        enabled: p.enabled,\n        name: p.name,\n        value: p.file ?? p.value,\n        contentType: p.contentType,\n        filename: p.filename,\n        isFile: !!p.file,\n        id: p.id,\n      })),\n    [request.body.form],\n  );\n\n  const handleChange = useCallback<PairEditorProps[\"onChange\"]>(\n    (pairs) =>\n      onChange({\n        form: pairs.map((p) => ({\n          enabled: p.enabled,\n          name: p.name,\n          contentType: p.contentType,\n          filename: p.filename,\n          file: p.isFile ? p.value : undefined,\n          value: p.isFile ? undefined : p.value,\n          id: p.id,\n        })),\n      }),\n    [onChange],\n  );\n\n  return (\n    <PairEditor\n      valueAutocompleteFunctions\n      valueAutocompleteVariables\n      nameAutocompleteVariables\n      nameAutocompleteFunctions\n      allowFileValues\n      allowMultilineValues\n      pairs={pairs}\n      onChange={handleChange}\n      forceUpdateKey={forceUpdateKey}\n      stateKey={`multipart.${request.id}`}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/FormUrlencodedEditor.tsx",
    "content": "import type { HttpRequest } from \"@yaakapp-internal/models\";\nimport { useCallback, useMemo } from \"react\";\nimport type { Pair, PairEditorProps } from \"./core/PairEditor\";\nimport { PairOrBulkEditor } from \"./core/PairOrBulkEditor\";\n\ntype Props = {\n  forceUpdateKey: string;\n  request: HttpRequest;\n  onChange: (headers: HttpRequest[\"body\"]) => void;\n};\n\nexport function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Props) {\n  const pairs = useMemo<Pair[]>(\n    () =>\n      (Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({\n        enabled: !!p.enabled,\n        name: p.name || \"\",\n        value: p.value || \"\",\n        id: p.id,\n      })),\n    [request.body.form],\n  );\n\n  const handleChange = useCallback<PairEditorProps[\"onChange\"]>(\n    (pairs) =>\n      onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }),\n    [onChange],\n  );\n\n  return (\n    <PairOrBulkEditor\n      allowMultilineValues\n      preferenceName=\"form_urlencoded\"\n      valueAutocompleteFunctions\n      valueAutocompleteVariables\n      nameAutocompleteFunctions\n      nameAutocompleteVariables\n      namePlaceholder=\"entry_name\"\n      valuePlaceholder=\"Value\"\n      pairs={pairs}\n      onChange={handleChange}\n      forceUpdateKey={forceUpdateKey}\n      stateKey={`urlencoded.${request.id}`}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/GlobalHooks.tsx",
    "content": "import { activeRequestAtom } from \"../hooks/useActiveRequest\";\nimport { useSubscribeActiveWorkspaceId } from \"../hooks/useActiveWorkspace\";\nimport { useActiveWorkspaceChangedToast } from \"../hooks/useActiveWorkspaceChangedToast\";\nimport { useHotKey, useSubscribeHotKeys } from \"../hooks/useHotKey\";\nimport { useSubscribeHttpAuthentication } from \"../hooks/useHttpAuthentication\";\nimport { useSyncFontSizeSetting } from \"../hooks/useSyncFontSizeSetting\";\nimport { useSyncWorkspaceChildModels } from \"../hooks/useSyncWorkspaceChildModels\";\nimport { useSyncZoomSetting } from \"../hooks/useSyncZoomSetting\";\nimport { useSubscribeTemplateFunctions } from \"../hooks/useTemplateFunctions\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { renameModelWithPrompt } from \"../lib/renameModelWithPrompt\";\n\nexport function GlobalHooks() {\n  useSyncZoomSetting();\n  useSyncFontSizeSetting();\n\n  useSubscribeActiveWorkspaceId();\n\n  useSyncWorkspaceChildModels();\n  useSubscribeTemplateFunctions();\n  useSubscribeHttpAuthentication();\n\n  // Other useful things\n  useActiveWorkspaceChangedToast();\n  useSubscribeHotKeys();\n\n  useHotKey(\n    \"request.rename\",\n    async () => {\n      const model = jotaiStore.get(activeRequestAtom);\n      if (model == null) return;\n      await renameModelWithPrompt(model);\n    },\n    { allowDefault: true },\n  );\n\n  return null;\n}\n"
  },
  {
    "path": "src-web/components/GrpcConnectionLayout.tsx",
    "content": "import { patchModel } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport type { CSSProperties } from \"react\";\nimport { useEffect, useMemo } from \"react\";\nimport { useActiveRequest } from \"../hooks/useActiveRequest\";\nimport { useGrpc } from \"../hooks/useGrpc\";\nimport { useGrpcProtoFiles } from \"../hooks/useGrpcProtoFiles\";\nimport { activeGrpcConnectionAtom, useGrpcEvents } from \"../hooks/usePinnedGrpcConnection\";\nimport { workspaceLayoutAtom } from \"../lib/atoms\";\nimport { Banner } from \"./core/Banner\";\nimport { HotkeyList } from \"./core/HotkeyList\";\nimport { SplitLayout } from \"./core/SplitLayout\";\nimport { GrpcRequestPane } from \"./GrpcRequestPane\";\nimport { GrpcResponsePane } from \"./GrpcResponsePane\";\n\ninterface Props {\n  style: CSSProperties;\n}\n\nconst emptyArray: string[] = [];\n\nexport function GrpcConnectionLayout({ style }: Props) {\n  const workspaceLayout = useAtomValue(workspaceLayoutAtom);\n  const activeRequest = useActiveRequest(\"grpc_request\");\n  const activeConnection = useAtomValue(activeGrpcConnectionAtom);\n  const grpcEvents = useGrpcEvents(activeConnection?.id ?? null);\n  const protoFilesKv = useGrpcProtoFiles(activeRequest?.id ?? null);\n  const protoFiles = protoFilesKv.value ?? emptyArray;\n  const grpc = useGrpc(activeRequest, activeConnection, protoFiles);\n\n  const services = grpc.reflect.data ?? null;\n  useEffect(() => {\n    if (services == null || activeRequest == null) return;\n    const s = services.find((s) => s.name === activeRequest.service);\n    if (s == null) {\n      patchModel(activeRequest, {\n        service: services[0]?.name ?? null,\n        method: services[0]?.methods[0]?.name ?? null,\n      }).catch(console.error);\n      return;\n    }\n\n    const m = s.methods.find((m) => m.name === activeRequest.method);\n    if (m == null) {\n      patchModel(activeRequest, {\n        method: s.methods[0]?.name ?? null,\n      }).catch(console.error);\n      return;\n    }\n  }, [activeRequest, services]);\n\n  const activeMethod = useMemo(() => {\n    if (services == null || activeRequest == null) return null;\n\n    const s = services.find((s) => s.name === activeRequest.service);\n    if (s == null) return null;\n    return s.methods.find((m) => m.name === activeRequest.method);\n  }, [activeRequest, services]);\n\n  const methodType:\n    | \"unary\"\n    | \"server_streaming\"\n    | \"client_streaming\"\n    | \"streaming\"\n    | \"no-schema\"\n    | \"no-method\" = useMemo(() => {\n    if (services == null) return \"no-schema\";\n    if (activeMethod == null) return \"no-method\";\n    if (activeMethod.clientStreaming && activeMethod.serverStreaming) return \"streaming\";\n    if (activeMethod.clientStreaming) return \"client_streaming\";\n    if (activeMethod.serverStreaming) return \"server_streaming\";\n    return \"unary\";\n  }, [activeMethod, services]);\n\n  if (activeRequest == null) {\n    return null;\n  }\n\n  return (\n    <SplitLayout\n      name=\"grpc_layout\"\n      className=\"p-3 gap-1.5\"\n      style={style}\n      layout={workspaceLayout}\n      firstSlot={({ style }) => (\n        <GrpcRequestPane\n          style={style}\n          activeRequest={activeRequest}\n          protoFiles={protoFiles}\n          methodType={methodType}\n          isStreaming={grpc.isStreaming}\n          onGo={grpc.go.mutate}\n          onCommit={grpc.commit.mutate}\n          onCancel={grpc.cancel.mutate}\n          onSend={grpc.send.mutate}\n          services={services ?? null}\n          reflectionError={grpc.reflect.error as string | undefined}\n          reflectionLoading={grpc.reflect.isFetching}\n        />\n      )}\n      secondSlot={({ style }) =>\n        !grpc.go.isPending && (\n          <div\n            style={style}\n            className={classNames(\n              \"x-theme-responsePane\",\n              \"max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1\",\n              \"bg-surface rounded-md border border-border-subtle\",\n              \"shadow relative\",\n            )}\n          >\n            {grpc.go.error ? (\n              <Banner color=\"danger\" className=\"m-2\">\n                {grpc.go.error}\n              </Banner>\n            ) : grpcEvents.length >= 0 ? (\n              <GrpcResponsePane activeRequest={activeRequest} methodType={methodType} />\n            ) : (\n              <HotkeyList hotkeys={[\"request.send\", \"sidebar.focus\", \"url_bar.focus\"]} />\n            )}\n          </div>\n        )\n      }\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/GrpcEditor.tsx",
    "content": "import { jsoncLanguage } from \"@shopify/lang-jsonc\";\nimport { linter } from \"@codemirror/lint\";\nimport type { EditorView } from \"@codemirror/view\";\nimport type { GrpcRequest } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport {\n  handleRefresh,\n  jsonCompletion,\n  jsonSchemaLinter,\n  stateExtensions,\n  updateSchema,\n} from \"codemirror-json-schema\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport type { ReflectResponseService } from \"../hooks/useGrpc\";\nimport { showAlert } from \"../lib/alert\";\nimport { showDialog } from \"../lib/dialog\";\nimport { pluralizeCount } from \"../lib/pluralize\";\nimport { Button } from \"./core/Button\";\nimport type { EditorProps } from \"./core/Editor/Editor\";\nimport { Editor } from \"./core/Editor/LazyEditor\";\nimport { FormattedError } from \"./core/FormattedError\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport { VStack } from \"./core/Stacks\";\nimport { GrpcProtoSelectionDialog } from \"./GrpcProtoSelectionDialog\";\n\ntype Props = Pick<EditorProps, \"heightMode\" | \"onChange\" | \"className\" | \"forceUpdateKey\"> & {\n  services: ReflectResponseService[] | null;\n  reflectionError?: string;\n  reflectionLoading?: boolean;\n  request: GrpcRequest;\n  protoFiles: string[];\n};\n\nexport function GrpcEditor({\n  services,\n  reflectionError,\n  reflectionLoading,\n  request,\n  protoFiles,\n  ...extraEditorProps\n}: Props) {\n  const [editorView, setEditorView] = useState<EditorView | null>(null);\n  const handleInitEditorViewRef = useCallback((h: EditorView | null) => {\n    setEditorView(h);\n  }, []);\n\n  // Find the schema for the selected service and method and update the editor\n  useEffect(() => {\n    if (\n      editorView == null ||\n      services === null ||\n      request.service === null ||\n      request.method === null\n    ) {\n      return;\n    }\n\n    const s = services.find((s) => s.name === request.service);\n    if (s == null) {\n      console.log(\"Failed to find service\", { service: request.service, services });\n      showAlert({\n        id: \"grpc-find-service-error\",\n        title: \"Couldn't Find Service\",\n        body: (\n          <>\n            Failed to find service <InlineCode>{request.service}</InlineCode> in schema\n          </>\n        ),\n      });\n      return;\n    }\n\n    const schema = s.methods.find((m) => m.name === request.method)?.schema;\n    if (request.method != null && schema == null) {\n      console.log(\"Failed to find method\", { method: request.method, methods: s?.methods });\n      showAlert({\n        id: \"grpc-find-schema-error\",\n        title: \"Couldn't Find Method\",\n        body: (\n          <>\n            Failed to find method <InlineCode>{request.method}</InlineCode> for{\" \"}\n            <InlineCode>{request.service}</InlineCode> in schema\n          </>\n        ),\n      });\n      return;\n    }\n\n    if (schema == null) {\n      return;\n    }\n\n    try {\n      updateSchema(editorView, JSON.parse(schema));\n    } catch (err) {\n      showAlert({\n        id: \"grpc-parse-schema-error\",\n        title: \"Failed to Parse Schema\",\n        body: (\n          <VStack space={4}>\n            <p>\n              For service <InlineCode>{request.service}</InlineCode> and method{\" \"}\n              <InlineCode>{request.method}</InlineCode>\n            </p>\n            <FormattedError>{String(err)}</FormattedError>\n          </VStack>\n        ),\n      });\n    }\n  }, [editorView, services, request.method, request.service]);\n\n  const extraExtensions = useMemo(\n    () => [\n      linter(jsonSchemaLinter(), {\n        delay: 200,\n        needsRefresh: handleRefresh,\n      }),\n      jsoncLanguage.data.of({\n        autocomplete: jsonCompletion(),\n      }),\n      stateExtensions({}),\n    ],\n    [],\n  );\n\n  const reflectionUnavailable = reflectionError?.match(/unimplemented/i);\n  reflectionError = reflectionUnavailable ? undefined : reflectionError;\n\n  const actions = useMemo(\n    () => [\n      <div key=\"reflection\" className={classNames(services == null && \"!opacity-100\")}>\n        <Button\n          size=\"xs\"\n          color={\n            reflectionLoading\n              ? \"secondary\"\n              : reflectionUnavailable\n                ? \"info\"\n                : reflectionError\n                  ? \"danger\"\n                  : \"secondary\"\n          }\n          isLoading={reflectionLoading}\n          onClick={() => {\n            showDialog({\n              title: \"Configure Schema\",\n              size: \"md\",\n              id: \"reflection-failed\",\n              render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,\n            });\n          }}\n        >\n          {reflectionLoading\n            ? \"Inspecting Schema\"\n            : reflectionUnavailable\n              ? \"Select Proto Files\"\n              : reflectionError\n                ? \"Server Error\"\n                : protoFiles.length > 0\n                  ? pluralizeCount(\"File\", protoFiles.length)\n                  : services != null && protoFiles.length === 0\n                    ? \"Schema Detected\"\n                    : \"Select Schema\"}\n        </Button>\n      </div>,\n    ],\n    [protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],\n  );\n\n  return (\n    <div className=\"h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]\">\n      <Editor\n        setRef={handleInitEditorViewRef}\n        language=\"json\"\n        autocompleteFunctions\n        autocompleteVariables\n        defaultValue={request.message}\n        heightMode=\"auto\"\n        placeholder=\"...\"\n        extraExtensions={extraExtensions}\n        actions={actions}\n        stateKey={`grpc_message.${request.id}`}\n        {...extraEditorProps}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/GrpcProtoSelectionDialog.tsx",
    "content": "import { open } from \"@tauri-apps/plugin-dialog\";\nimport type { GrpcRequest } from \"@yaakapp-internal/models\";\nimport { useActiveRequest } from \"../hooks/useActiveRequest\";\nimport { useGrpc } from \"../hooks/useGrpc\";\nimport { useGrpcProtoFiles } from \"../hooks/useGrpcProtoFiles\";\nimport { pluralizeCount } from \"../lib/pluralize\";\nimport { Banner } from \"./core/Banner\";\nimport { Button } from \"./core/Button\";\nimport { Icon } from \"./core/Icon\";\nimport { IconButton } from \"./core/IconButton\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport { Link } from \"./core/Link\";\nimport { HStack, VStack } from \"./core/Stacks\";\n\ninterface Props {\n  onDone: () => void;\n}\n\nexport function GrpcProtoSelectionDialog(props: Props) {\n  const request = useActiveRequest();\n  if (request?.model !== \"grpc_request\") return null;\n\n  return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;\n}\n\nfunction GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) {\n  const protoFilesKv = useGrpcProtoFiles(request.id);\n  const protoFiles = protoFilesKv.value ?? [];\n  const grpc = useGrpc(request, null, protoFiles);\n  const services = grpc.reflect.data;\n  const serverReflection = protoFiles.length === 0 && services != null;\n  let reflectError = grpc.reflect.error ?? null;\n  const reflectionUnimplemented = `${reflectError}`.match(/unimplemented/i);\n\n  if (reflectionUnimplemented) {\n    reflectError = null;\n  }\n\n  if (request == null) {\n    return null;\n  }\n\n  return (\n    <VStack className=\"flex-col-reverse mb-3\" space={3}>\n      {/* Buttons on top so they get focus first */}\n      <HStack space={2} justifyContent=\"start\" className=\"flex-row-reverse mt-3\">\n        <Button\n          color=\"primary\"\n          variant=\"border\"\n          onClick={async () => {\n            const selected = await open({\n              title: \"Select Proto Files\",\n              multiple: true,\n              filters: [{ name: \"Proto Files\", extensions: [\"proto\"] }],\n            });\n            if (selected == null) return;\n\n            const newFiles = selected.filter((p) => !protoFiles.includes(p));\n            await protoFilesKv.set([...protoFiles, ...newFiles]);\n            await grpc.reflect.refetch();\n          }}\n        >\n          Add Proto Files\n        </Button>\n        <Button\n          variant=\"border\"\n          color=\"primary\"\n          onClick={async () => {\n            const selected = await open({\n              title: \"Select Proto Directory\",\n              directory: true,\n            });\n            if (selected == null) return;\n\n            await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);\n            await grpc.reflect.refetch();\n          }}\n        >\n          Add Import Folders\n        </Button>\n        <Button\n          isLoading={grpc.reflect.isFetching}\n          disabled={grpc.reflect.isFetching}\n          variant=\"border\"\n          color=\"secondary\"\n          onClick={() => grpc.reflect.refetch()}\n        >\n          Refresh Schema\n        </Button>\n      </HStack>\n      <VStack space={5}>\n        {reflectError && (\n          <Banner color=\"warning\">\n            <h1 className=\"font-bold\">\n              Reflection failed on URL <InlineCode>{request.url || \"n/a\"}</InlineCode>\n            </h1>\n            <p>{reflectError.trim()}</p>\n          </Banner>\n        )}\n        {!serverReflection && services != null && services.length > 0 && (\n          <Banner className=\"flex flex-col gap-2\">\n            <p>\n              Found services{\" \"}\n              {services?.slice(0, 5).map((s, i) => {\n                return (\n                  <span key={s.name + s.methods.map((m) => m.name).join(\",\")}>\n                    <InlineCode>{s.name}</InlineCode>\n                    {i === services.length - 1 ? \"\" : i === services.length - 2 ? \" and \" : \", \"}\n                  </span>\n                );\n              })}\n              {services?.length > 5 && pluralizeCount(\"other\", services?.length - 5)}\n            </p>\n          </Banner>\n        )}\n        {serverReflection && services != null && services.length > 0 && (\n          <Banner className=\"flex flex-col gap-2\">\n            <p>\n              Server reflection found services\n              {services?.map((s, i) => {\n                return (\n                  <span key={s.name + s.methods.map((m) => m.name).join(\",\")}>\n                    <InlineCode>{s.name}</InlineCode>\n                    {i === services.length - 1 ? \"\" : i === services.length - 2 ? \" and \" : \", \"}\n                  </span>\n                );\n              })}\n              . You can override this schema by manually selecting <InlineCode>*.proto</InlineCode>{\" \"}\n              files.\n            </p>\n          </Banner>\n        )}\n\n        {protoFiles.length > 0 && (\n          <table className=\"w-full divide-y divide-surface-highlight\">\n            <thead>\n              <tr>\n                <th className=\"text-text-subtlest\" colSpan={3}>\n                  Added File Paths\n                </th>\n              </tr>\n            </thead>\n            <tbody className=\"divide-y divide-surface-highlight\">\n              {protoFiles.map((f, i) => {\n                const parts = f.split(\"/\");\n                return (\n                  // oxlint-disable-next-line react/no-array-index-key\n                  <tr key={f + i} className=\"group\">\n                    <td>\n                      <Icon icon={f.endsWith(\".proto\") ? \"file_code\" : \"folder_code\"} />\n                    </td>\n                    <td className=\"pl-1 font-mono text-sm\" title={f}>\n                      {parts.length > 3 && \".../\"}\n                      {parts.slice(-3).join(\"/\")}\n                    </td>\n                    <td className=\"w-0 py-0.5\">\n                      <IconButton\n                        title=\"Remove file\"\n                        variant=\"border\"\n                        size=\"xs\"\n                        icon=\"trash\"\n                        className=\"my-0.5 ml-auto opacity-50 transition-opacity group-hover:opacity-100\"\n                        onClick={async () => {\n                          await protoFilesKv.set(protoFiles.filter((p) => p !== f));\n                        }}\n                      />\n                    </td>\n                  </tr>\n                );\n              })}\n            </tbody>\n          </table>\n        )}\n        {reflectionUnimplemented && protoFiles.length === 0 && (\n          <Banner>\n            <InlineCode>{request.url}</InlineCode> doesn&apos;t implement{\" \"}\n            <Link href=\"https://github.com/grpc/grpc/blob/9aa3c5835a4ed6afae9455b63ed45c761d695bca/doc/server-reflection.md\">\n              Server Reflection\n            </Link>{\" \"}\n            . Please manually add the <InlineCode>.proto</InlineCode> file to get started.\n          </Banner>\n        )}\n      </VStack>\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/GrpcRequestPane.tsx",
    "content": "import { type GrpcRequest, type HttpRequestHeader, patchModel } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport type { CSSProperties } from \"react\";\nimport { useCallback, useMemo, useRef } from \"react\";\nimport { useAuthTab } from \"../hooks/useAuthTab\";\nimport { useContainerSize } from \"../hooks/useContainerQuery\";\nimport type { ReflectResponseService } from \"../hooks/useGrpc\";\nimport { useHeadersTab } from \"../hooks/useHeadersTab\";\nimport { useInheritedHeaders } from \"../hooks/useInheritedHeaders\";\nimport { useRequestUpdateKey } from \"../hooks/useRequestUpdateKey\";\nimport { resolvedModelName } from \"../lib/resolvedModelName\";\nimport { Button } from \"./core/Button\";\nimport { CountBadge } from \"./core/CountBadge\";\nimport { Icon } from \"./core/Icon\";\nimport { IconButton } from \"./core/IconButton\";\nimport { PlainInput } from \"./core/PlainInput\";\nimport { RadioDropdown } from \"./core/RadioDropdown\";\nimport { HStack, VStack } from \"./core/Stacks\";\nimport type { TabItem } from \"./core/Tabs/Tabs\";\nimport { TabContent, Tabs } from \"./core/Tabs/Tabs\";\nimport { GrpcEditor } from \"./GrpcEditor\";\nimport { HeadersEditor } from \"./HeadersEditor\";\nimport { HttpAuthenticationEditor } from \"./HttpAuthenticationEditor\";\nimport { MarkdownEditor } from \"./MarkdownEditor\";\nimport { UrlBar } from \"./UrlBar\";\n\ninterface Props {\n  style?: CSSProperties;\n  className?: string;\n  activeRequest: GrpcRequest;\n  protoFiles: string[];\n  reflectionError?: string;\n  reflectionLoading?: boolean;\n  methodType:\n    | \"unary\"\n    | \"client_streaming\"\n    | \"server_streaming\"\n    | \"streaming\"\n    | \"no-schema\"\n    | \"no-method\";\n  isStreaming: boolean;\n  onCommit: () => void;\n  onCancel: () => void;\n  onSend: (v: { message: string }) => void;\n  onGo: () => void;\n  services: ReflectResponseService[] | null;\n}\n\nconst TAB_MESSAGE = \"message\";\nconst TAB_METADATA = \"metadata\";\nconst TAB_AUTH = \"auth\";\nconst TAB_DESCRIPTION = \"description\";\n\nexport function GrpcRequestPane({\n  style,\n  services,\n  methodType,\n  activeRequest,\n  protoFiles,\n  reflectionError,\n  reflectionLoading,\n  isStreaming,\n  onGo,\n  onCommit,\n  onCancel,\n  onSend,\n}: Props) {\n  const authTab = useAuthTab(TAB_AUTH, activeRequest);\n  const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, \"Metadata\");\n  const inheritedHeaders = useInheritedHeaders(activeRequest);\n  const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);\n\n  const urlContainerEl = useRef<HTMLDivElement>(null);\n  const { width: paneWidth } = useContainerSize(urlContainerEl);\n\n  const handleChangeUrl = useCallback(\n    (url: string) => patchModel(activeRequest, { url }),\n    [activeRequest],\n  );\n\n  const handleChangeMessage = useCallback(\n    (message: string) => patchModel(activeRequest, { message }),\n    [activeRequest],\n  );\n\n  const select = useMemo(() => {\n    const options =\n      services?.flatMap((s) =>\n        s.methods.map((m) => ({\n          label: `${s.name.split(\".\").pop() ?? s.name}/${m.name}`,\n          value: `${s.name}/${m.name}`,\n        })),\n      ) ?? [];\n    const value = `${activeRequest?.service ?? \"\"}/${activeRequest?.method ?? \"\"}`;\n    return { value, options };\n  }, [activeRequest?.method, activeRequest?.service, services]);\n\n  const handleChangeService = useCallback(\n    async (v: string) => {\n      const [serviceName, methodName] = v.split(\"/\", 2);\n      if (serviceName == null || methodName == null) throw new Error(\"Should never happen\");\n      await patchModel(activeRequest, {\n        service: serviceName,\n        method: methodName,\n      });\n    },\n    [activeRequest],\n  );\n\n  const handleConnect = useCallback(async () => {\n    if (activeRequest == null) return;\n\n    if (activeRequest.service == null || activeRequest.method == null) {\n      alert({\n        id: \"grpc-invalid-service-method\",\n        title: \"Error\",\n        body: \"Service or method not selected\",\n      });\n    }\n    onGo();\n  }, [activeRequest, onGo]);\n\n  const handleSend = useCallback(async () => {\n    if (activeRequest == null) return;\n    onSend({ message: activeRequest.message });\n  }, [activeRequest, onSend]);\n\n  const tabs: TabItem[] = useMemo(\n    () => [\n      { value: TAB_MESSAGE, label: \"Message\" },\n      ...metadataTab,\n      ...authTab,\n      {\n        value: TAB_DESCRIPTION,\n        label: \"Info\",\n        rightSlot: activeRequest.description && <CountBadge count={true} />,\n      },\n    ],\n    [activeRequest.description, authTab, metadataTab],\n  );\n\n  const handleMetadataChange = useCallback(\n    (metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),\n    [activeRequest],\n  );\n\n  const handleDescriptionChange = useCallback(\n    (description: string) => patchModel(activeRequest, { description }),\n    [activeRequest],\n  );\n\n  return (\n    <VStack style={style}>\n      <div\n        ref={urlContainerEl}\n        className={classNames(\n          \"grid grid-cols-[minmax(0,1fr)_auto] gap-1.5\",\n          paneWidth === 0 && \"opacity-0\",\n          paneWidth > 0 && paneWidth < 400 && \"!grid-cols-1\",\n        )}\n      >\n        <UrlBar\n          key={forceUpdateKey}\n          url={activeRequest.url ?? \"\"}\n          submitIcon={null}\n          forceUpdateKey={forceUpdateKey}\n          placeholder=\"localhost:50051\"\n          onSend={handleConnect}\n          onUrlChange={handleChangeUrl}\n          onCancel={onCancel}\n          isLoading={isStreaming}\n          stateKey={`grpc_url.${activeRequest.id}`}\n        />\n        <HStack space={1.5}>\n          <RadioDropdown\n            value={select.value}\n            onChange={handleChangeService}\n            items={select.options.map((o) => ({\n              label: o.label,\n              value: o.value,\n              type: \"default\",\n              shortLabel: o.label,\n            }))}\n            itemsAfter={[\n              {\n                label: \"Refresh\",\n                type: \"default\",\n                leftSlot: <Icon size=\"sm\" icon=\"refresh\" />,\n              },\n            ]}\n          >\n            <Button\n              size=\"sm\"\n              variant=\"border\"\n              rightSlot={<Icon size=\"sm\" icon=\"chevron_down\" />}\n              disabled={isStreaming || services == null}\n              className={classNames(\n                \"font-mono text-editor min-w-[5rem] !ring-0\",\n                paneWidth < 400 && \"flex-1\",\n              )}\n            >\n              {select.options.find((o) => o.value === select.value)?.label ?? \"No Schema\"}\n            </Button>\n          </RadioDropdown>\n          {methodType === \"client_streaming\" || methodType === \"streaming\" ? (\n            <>\n              {isStreaming && (\n                <>\n                  <IconButton\n                    variant=\"border\"\n                    size=\"sm\"\n                    title=\"Cancel\"\n                    onClick={onCancel}\n                    icon=\"x\"\n                  />\n                  <IconButton\n                    variant=\"border\"\n                    size=\"sm\"\n                    title=\"Commit\"\n                    onClick={onCommit}\n                    icon=\"check\"\n                  />\n                </>\n              )}\n              <IconButton\n                size=\"sm\"\n                variant=\"border\"\n                title={isStreaming ? \"Connect\" : \"Send\"}\n                hotkeyAction=\"request.send\"\n                onClick={isStreaming ? handleSend : handleConnect}\n                icon={isStreaming ? \"send_horizontal\" : \"arrow_up_down\"}\n              />\n            </>\n          ) : (\n            <IconButton\n              size=\"sm\"\n              variant=\"border\"\n              title={methodType === \"unary\" ? \"Send\" : \"Connect\"}\n              hotkeyAction=\"request.send\"\n              onClick={isStreaming ? onCancel : handleConnect}\n              disabled={methodType === \"no-schema\" || methodType === \"no-method\"}\n              icon={\n                isStreaming\n                  ? \"x\"\n                  : methodType.includes(\"streaming\")\n                    ? \"arrow_up_down\"\n                    : \"send_horizontal\"\n              }\n            />\n          )}\n        </HStack>\n      </div>\n      <Tabs\n        label=\"Request\"\n        tabs={tabs}\n        tabListClassName=\"mt-1 !mb-1.5\"\n        storageKey=\"grpc_request_tabs\"\n        activeTabKey={activeRequest.id}\n      >\n        <TabContent value=\"message\">\n          <GrpcEditor\n            onChange={handleChangeMessage}\n            forceUpdateKey={forceUpdateKey}\n            services={services}\n            reflectionError={reflectionError}\n            reflectionLoading={reflectionLoading}\n            request={activeRequest}\n            protoFiles={protoFiles}\n          />\n        </TabContent>\n        <TabContent value={TAB_AUTH}>\n          <HttpAuthenticationEditor model={activeRequest} />\n        </TabContent>\n        <TabContent value={TAB_METADATA}>\n          <HeadersEditor\n            inheritedHeaders={inheritedHeaders}\n            forceUpdateKey={forceUpdateKey}\n            headers={activeRequest.metadata}\n            stateKey={`headers.${activeRequest.id}`}\n            onChange={handleMetadataChange}\n          />\n        </TabContent>\n        <TabContent value={TAB_DESCRIPTION}>\n          <div className=\"grid grid-rows-[auto_minmax(0,1fr)] h-full\">\n            <PlainInput\n              label=\"Request Name\"\n              hideLabel\n              forceUpdateKey={forceUpdateKey}\n              defaultValue={activeRequest.name}\n              className=\"font-sans !text-xl !px-0\"\n              containerClassName=\"border-0\"\n              placeholder={resolvedModelName(activeRequest)}\n              onChange={(name) => patchModel(activeRequest, { name })}\n            />\n            <MarkdownEditor\n              name=\"request-description\"\n              placeholder=\"Request description\"\n              defaultValue={activeRequest.description}\n              stateKey={`description.${activeRequest.id}`}\n              onChange={handleDescriptionChange}\n            />\n          </div>\n        </TabContent>\n      </Tabs>\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/GrpcResponsePane.tsx",
    "content": "import type { GrpcEvent, GrpcRequest } from \"@yaakapp-internal/models\";\nimport { useAtomValue, useSetAtom } from \"jotai\";\nimport type { CSSProperties } from \"react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport {\n  activeGrpcConnectionAtom,\n  activeGrpcConnections,\n  pinnedGrpcConnectionIdAtom,\n  useGrpcEvents,\n} from \"../hooks/usePinnedGrpcConnection\";\nimport { useStateWithDeps } from \"../hooks/useStateWithDeps\";\nimport { Button } from \"./core/Button\";\nimport { Editor } from \"./core/Editor/LazyEditor\";\nimport { EventDetailHeader, EventViewer } from \"./core/EventViewer\";\nimport { EventViewerRow } from \"./core/EventViewerRow\";\nimport { HotkeyList } from \"./core/HotkeyList\";\nimport { Icon, type IconProps } from \"./core/Icon\";\nimport { KeyValueRow, KeyValueRows } from \"./core/KeyValueRow\";\nimport { LoadingIcon } from \"./core/LoadingIcon\";\nimport { HStack, VStack } from \"./core/Stacks\";\nimport { EmptyStateText } from \"./EmptyStateText\";\nimport { ErrorBoundary } from \"./ErrorBoundary\";\nimport { RecentGrpcConnectionsDropdown } from \"./RecentGrpcConnectionsDropdown\";\n\ninterface Props {\n  style?: CSSProperties;\n  className?: string;\n  activeRequest: GrpcRequest;\n  methodType:\n    | \"unary\"\n    | \"client_streaming\"\n    | \"server_streaming\"\n    | \"streaming\"\n    | \"no-schema\"\n    | \"no-method\";\n}\n\nexport function GrpcResponsePane({ style, methodType, activeRequest }: Props) {\n  const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);\n  const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);\n  const [showingLarge, setShowingLarge] = useState<boolean>(false);\n  const connections = useAtomValue(activeGrpcConnections);\n  const activeConnection = useAtomValue(activeGrpcConnectionAtom);\n  const events = useGrpcEvents(activeConnection?.id ?? null);\n  const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);\n\n  const activeEvent = useMemo(\n    () => (activeEventIndex != null ? events[activeEventIndex] : null),\n    [activeEventIndex, events],\n  );\n\n  // Set the active message to the first message received if unary\n  // oxlint-disable-next-line react-hooks/exhaustive-deps\n  useEffect(() => {\n    if (events.length === 0 || activeEvent != null || methodType !== \"unary\") {\n      return;\n    }\n    const firstServerMessageIndex = events.findIndex((m) => m.eventType === \"server_message\");\n    if (firstServerMessageIndex !== -1) {\n      setActiveEventIndex(firstServerMessageIndex);\n    }\n  }, [events.length]);\n\n  if (activeConnection == null) {\n    return (\n      <HotkeyList hotkeys={[\"request.send\", \"model.create\", \"sidebar.focus\", \"url_bar.focus\"]} />\n    );\n  }\n\n  const header = (\n    <HStack className=\"pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars\">\n      <HStack space={2}>\n        <span className=\"whitespace-nowrap\">{events.length} Messages</span>\n        {activeConnection.state !== \"closed\" && (\n          <LoadingIcon size=\"sm\" className=\"text-text-subtlest\" />\n        )}\n      </HStack>\n      <div className=\"ml-auto\">\n        <RecentGrpcConnectionsDropdown\n          connections={connections}\n          activeConnection={activeConnection}\n          onPinnedConnectionId={setPinnedGrpcConnectionId}\n        />\n      </div>\n    </HStack>\n  );\n\n  return (\n    <div style={style} className=\"h-full\">\n      <ErrorBoundary name=\"GRPC Events\">\n        <EventViewer\n          events={events}\n          getEventKey={(event) => event.id}\n          error={activeConnection.error}\n          header={header}\n          splitLayoutName=\"grpc_events\"\n          defaultRatio={0.4}\n          renderRow={({ event, isActive, onClick }) => (\n            <GrpcEventRow event={event} isActive={isActive} onClick={onClick} />\n          )}\n          renderDetail={({ event, onClose }) => (\n            <GrpcEventDetail\n              event={event}\n              showLarge={showLarge}\n              showingLarge={showingLarge}\n              setShowLarge={setShowLarge}\n              setShowingLarge={setShowingLarge}\n              onClose={onClose}\n            />\n          )}\n        />\n      </ErrorBoundary>\n    </div>\n  );\n}\n\nfunction GrpcEventRow({\n  event,\n  isActive,\n  onClick,\n}: {\n  event: GrpcEvent;\n  isActive: boolean;\n  onClick: () => void;\n}) {\n  const { eventType, status, content, error } = event;\n  const display = getEventDisplay(eventType, status);\n\n  return (\n    <EventViewerRow\n      isActive={isActive}\n      onClick={onClick}\n      icon={<Icon color={display.color} title={display.title} icon={display.icon} />}\n      content={\n        <span className=\"text-xs\">\n          {content.slice(0, 1000)}\n          {error && <span className=\"text-warning\"> ({error})</span>}\n        </span>\n      }\n      timestamp={event.createdAt}\n    />\n  );\n}\n\nfunction GrpcEventDetail({\n  event,\n  showLarge,\n  showingLarge,\n  setShowLarge,\n  setShowingLarge,\n  onClose,\n}: {\n  event: GrpcEvent;\n  showLarge: boolean;\n  showingLarge: boolean;\n  setShowLarge: (v: boolean) => void;\n  setShowingLarge: (v: boolean) => void;\n  onClose: () => void;\n}) {\n  if (event.eventType === \"client_message\" || event.eventType === \"server_message\") {\n    const title = `Message ${event.eventType === \"client_message\" ? \"Sent\" : \"Received\"}`;\n\n    return (\n      <div className=\"h-full grid grid-rows-[auto_minmax(0,1fr)]\">\n        <EventDetailHeader\n          title={title}\n          timestamp={event.createdAt}\n          copyText={event.content}\n          onClose={onClose}\n        />\n        {!showLarge && event.content.length > 1000 * 1000 ? (\n          <VStack space={2} className=\"italic text-text-subtlest\">\n            Message previews larger than 1MB are hidden\n            <div>\n              <Button\n                onClick={() => {\n                  setShowingLarge(true);\n                  setTimeout(() => {\n                    setShowLarge(true);\n                    setShowingLarge(false);\n                  }, 500);\n                }}\n                isLoading={showingLarge}\n                color=\"secondary\"\n                variant=\"border\"\n                size=\"xs\"\n              >\n                Try Showing\n              </Button>\n            </div>\n          </VStack>\n        ) : (\n          <Editor\n            language=\"json\"\n            defaultValue={event.content ?? \"\"}\n            wrapLines={false}\n            readOnly={true}\n            stateKey={null}\n          />\n        )}\n      </div>\n    );\n  }\n\n  // Error or connection_end - show metadata/trailers\n  return (\n    <div className=\"h-full grid grid-rows-[auto_minmax(0,1fr)]\">\n      <EventDetailHeader title={event.content} timestamp={event.createdAt} onClose={onClose} />\n      {event.error && (\n        <div className=\"select-text cursor-text text-sm font-mono py-1 text-warning\">\n          {event.error}\n        </div>\n      )}\n      <div className=\"py-2 h-full\">\n        {Object.keys(event.metadata).length === 0 ? (\n          <EmptyStateText>\n            No {event.eventType === \"connection_end\" ? \"trailers\" : \"metadata\"}\n          </EmptyStateText>\n        ) : (\n          <KeyValueRows>\n            {Object.entries(event.metadata).map(([key, value]) => (\n              <KeyValueRow key={key} label={key}>\n                {value}\n              </KeyValueRow>\n            ))}\n          </KeyValueRows>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction getEventDisplay(\n  eventType: GrpcEvent[\"eventType\"],\n  status: GrpcEvent[\"status\"],\n): { icon: IconProps[\"icon\"]; color: IconProps[\"color\"]; title: string } {\n  if (eventType === \"server_message\") {\n    return { icon: \"arrow_big_down_dash\", color: \"info\", title: \"Server message\" };\n  }\n  if (eventType === \"client_message\") {\n    return { icon: \"arrow_big_up_dash\", color: \"primary\", title: \"Client message\" };\n  }\n  if (eventType === \"error\" || (status != null && status > 0)) {\n    return { icon: \"alert_triangle\", color: \"danger\", title: \"Error\" };\n  }\n  if (eventType === \"connection_end\") {\n    return { icon: \"check\", color: \"success\", title: \"Connection response\" };\n  }\n  return { icon: \"info\", color: undefined, title: \"Event\" };\n}\n"
  },
  {
    "path": "src-web/components/HeaderSize.tsx",
    "content": "import { type } from \"@tauri-apps/plugin-os\";\nimport { settingsAtom } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport type { CSSProperties, HTMLAttributes, ReactNode } from \"react\";\nimport { useMemo } from \"react\";\nimport { useIsFullscreen } from \"../hooks/useIsFullscreen\";\nimport { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from \"../lib/constants\";\nimport { WindowControls } from \"./WindowControls\";\n\ninterface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {\n  children?: ReactNode;\n  size: \"md\" | \"lg\";\n  ignoreControlsSpacing?: boolean;\n  onlyXWindowControl?: boolean;\n  hideControls?: boolean;\n}\n\nexport function HeaderSize({\n  className,\n  style,\n  size,\n  ignoreControlsSpacing,\n  onlyXWindowControl,\n  children,\n  hideControls,\n}: HeaderSizeProps) {\n  const settings = useAtomValue(settingsAtom);\n  const isFullscreen = useIsFullscreen();\n  const nativeTitlebar = settings.useNativeTitlebar;\n  const finalStyle = useMemo<CSSProperties>(() => {\n    const s = { ...style };\n\n    // Set the height (use min-height because scaling font size may make it larger\n    if (size === \"md\") s.minHeight = HEADER_SIZE_MD;\n    if (size === \"lg\") s.minHeight = HEADER_SIZE_LG;\n\n    if (nativeTitlebar) {\n      // No style updates when using native titlebar\n    } else if (type() === \"macos\") {\n      if (!isFullscreen) {\n        // Add large padding for window controls\n        s.paddingLeft = 76 / settings.interfaceScale;\n      }\n    } else if (!ignoreControlsSpacing && !settings.hideWindowControls) {\n      s.paddingRight = WINDOW_CONTROLS_WIDTH;\n    }\n\n    return s;\n  }, [\n    ignoreControlsSpacing,\n    isFullscreen,\n    settings.hideWindowControls,\n    settings.interfaceScale,\n    size,\n    style,\n    nativeTitlebar,\n  ]);\n\n  return (\n    <div\n      data-tauri-drag-region\n      style={finalStyle}\n      className={classNames(\n        className,\n        \"pt-[1px]\", // Make up for bottom border\n        \"select-none relative\",\n        \"w-full border-b border-border-subtle min-w-0\",\n      )}\n    >\n      {/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}\n      <div\n        className={classNames(\n          \"pointer-events-none h-full w-full overflow-x-auto hide-scrollbars grid\",\n          \"px-1\", // Give it some space on either end for focus outlines\n        )}\n      >\n        {children}\n      </div>\n      {!hideControls && !nativeTitlebar && <WindowControls onlyX={onlyXWindowControl} />}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/HeadersEditor.tsx",
    "content": "import type { HttpRequestHeader } from \"@yaakapp-internal/models\";\nimport type { GenericCompletionOption } from \"@yaakapp-internal/plugins\";\nimport { charsets } from \"../lib/data/charsets\";\nimport { connections } from \"../lib/data/connections\";\nimport { encodings } from \"../lib/data/encodings\";\nimport { headerNames } from \"../lib/data/headerNames\";\nimport { mimeTypes } from \"../lib/data/mimetypes\";\nimport { CountBadge } from \"./core/CountBadge\";\nimport { DetailsBanner } from \"./core/DetailsBanner\";\nimport type { GenericCompletionConfig } from \"./core/Editor/genericCompletion\";\nimport type { InputProps } from \"./core/Input\";\nimport type { Pair, PairEditorProps } from \"./core/PairEditor\";\nimport { PairEditorRow } from \"./core/PairEditor\";\nimport { ensurePairId } from \"./core/PairEditor.util\";\nimport { PairOrBulkEditor } from \"./core/PairOrBulkEditor\";\nimport { HStack } from \"./core/Stacks\";\n\ntype Props = {\n  forceUpdateKey: string;\n  headers: HttpRequestHeader[];\n  inheritedHeaders?: HttpRequestHeader[];\n  inheritedHeadersLabel?: string;\n  stateKey: string;\n  onChange: (headers: HttpRequestHeader[]) => void;\n  label?: string;\n};\n\nexport function HeadersEditor({\n  stateKey,\n  headers,\n  inheritedHeaders,\n  inheritedHeadersLabel = \"Inherited\",\n  onChange,\n  forceUpdateKey,\n}: Props) {\n  // Get header names defined at current level (case-insensitive)\n  const currentHeaderNames = new Set(\n    headers.filter((h) => h.name).map((h) => h.name.toLowerCase()),\n  );\n  // Filter inherited headers: must be enabled, have content, and not be overridden by current level\n  const validInheritedHeaders =\n    inheritedHeaders?.filter(\n      (pair) =>\n        pair.enabled &&\n        (pair.name || pair.value) &&\n        !currentHeaderNames.has(pair.name.toLowerCase()),\n    ) ?? [];\n  const hasInheritedHeaders = validInheritedHeaders.length > 0;\n  return (\n    <div\n      className={\n        hasInheritedHeaders\n          ? \"@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5\"\n          : \"@container w-full h-full\"\n      }\n    >\n      {hasInheritedHeaders && (\n        <DetailsBanner\n          color=\"secondary\"\n          className=\"text-sm\"\n          summary={\n            <HStack>\n              {inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />\n            </HStack>\n          }\n        >\n          <div className=\"pb-2\">\n            {validInheritedHeaders?.map((pair, i) => (\n              <PairEditorRow\n                key={`${pair.id}.${i}`}\n                index={i}\n                disabled\n                disableDrag\n                className=\"py-1\"\n                pair={ensurePairId(pair)}\n                stateKey={null}\n                nameAutocompleteFunctions\n                nameAutocompleteVariables\n                valueAutocompleteFunctions\n                valueAutocompleteVariables\n              />\n            ))}\n          </div>\n        </DetailsBanner>\n      )}\n      <PairOrBulkEditor\n        forceUpdateKey={forceUpdateKey}\n        nameAutocomplete={nameAutocomplete}\n        nameAutocompleteFunctions\n        nameAutocompleteVariables\n        namePlaceholder=\"Header-Name\"\n        nameValidate={validateHttpHeader}\n        onChange={onChange}\n        pairs={headers}\n        preferenceName=\"headers\"\n        stateKey={stateKey}\n        valueType={valueType}\n        valueAutocomplete={valueAutocomplete}\n        valueAutocompleteFunctions\n        valueAutocompleteVariables\n      />\n    </div>\n  );\n}\n\nconst MIN_MATCH = 3;\n\nconst headerOptionsMap: Record<string, string[]> = {\n  \"content-type\": mimeTypes,\n  accept: [\"*/*\", ...mimeTypes],\n  \"accept-encoding\": encodings,\n  connection: connections,\n  \"accept-charset\": charsets,\n};\n\nconst valueType = (pair: Pair): InputProps[\"type\"] => {\n  const name = pair.name.toLowerCase().trim();\n  if (\n    name.includes(\"authorization\") ||\n    name.includes(\"api-key\") ||\n    name.includes(\"access-token\") ||\n    name.includes(\"auth\") ||\n    name.includes(\"secret\") ||\n    name.includes(\"token\") ||\n    name === \"cookie\" ||\n    name === \"set-cookie\"\n  ) {\n    return \"password\";\n  }\n  return \"text\";\n};\n\nconst valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {\n  const name = headerName.toLowerCase().trim();\n  const options: GenericCompletionOption[] =\n    headerOptionsMap[name]?.map((o) => ({\n      label: o,\n      type: \"constant\",\n      boost: 1, // Put above other completions\n    })) ?? [];\n  return { minMatch: MIN_MATCH, options };\n};\n\nconst nameAutocomplete: PairEditorProps[\"nameAutocomplete\"] = {\n  minMatch: MIN_MATCH,\n  options: headerNames.map((t) =>\n    typeof t === \"string\"\n      ? {\n          label: t,\n          type: \"constant\",\n          boost: 1, // Put above other completions\n        }\n      : {\n          ...t,\n          boost: 1, // Put above other completions\n        },\n  ),\n};\n\nconst validateHttpHeader = (v: string) => {\n  if (v === \"\") {\n    return true;\n  }\n\n  // Template strings are not allowed so we replace them with a valid example string\n  const withoutTemplateStrings = v.replace(/\\$\\{\\[\\s*[^\\]\\s]+\\s*]}/gi, \"123\");\n  return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;\n};\n"
  },
  {
    "path": "src-web/components/HttpAuthenticationEditor.tsx",
    "content": "import type {\n  Folder,\n  GrpcRequest,\n  HttpRequest,\n  WebsocketRequest,\n  Workspace,\n} from \"@yaakapp-internal/models\";\nimport { patchModel } from \"@yaakapp-internal/models\";\nimport { useCallback } from \"react\";\nimport { openFolderSettings } from \"../commands/openFolderSettings\";\nimport { openWorkspaceSettings } from \"../commands/openWorkspaceSettings\";\nimport { useHttpAuthenticationConfig } from \"../hooks/useHttpAuthenticationConfig\";\nimport { useInheritedAuthentication } from \"../hooks/useInheritedAuthentication\";\nimport { useRenderTemplate } from \"../hooks/useRenderTemplate\";\nimport { resolvedModelName } from \"../lib/resolvedModelName\";\nimport { Dropdown, type DropdownItem } from \"./core/Dropdown\";\nimport { Icon } from \"./core/Icon\";\nimport { IconButton } from \"./core/IconButton\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport { Input, type InputProps } from \"./core/Input\";\nimport { Link } from \"./core/Link\";\nimport { SegmentedControl } from \"./core/SegmentedControl\";\nimport { HStack } from \"./core/Stacks\";\nimport { DynamicForm } from \"./DynamicForm\";\nimport { EmptyStateText } from \"./EmptyStateText\";\n\ninterface Props {\n  model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;\n}\n\nexport function HttpAuthenticationEditor({ model }: Props) {\n  const inheritedAuth = useInheritedAuthentication(model);\n  const authConfig = useHttpAuthenticationConfig(\n    model.authenticationType,\n    model.authentication,\n    model,\n  );\n\n  const handleChange = useCallback(\n    async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }),\n    [model],\n  );\n\n  if (model.authenticationType === \"none\") {\n    return <EmptyStateText>No authentication</EmptyStateText>;\n  }\n\n  if (model.authenticationType != null && authConfig.data == null) {\n    return (\n      <EmptyStateText>\n        <p>\n          Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode>\n        </p>\n      </EmptyStateText>\n    );\n  }\n\n  if (inheritedAuth == null) {\n    if (model.model === \"workspace\" || model.model === \"folder\") {\n      return (\n        <EmptyStateText className=\"flex-col gap-1\">\n          <p>\n            Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>\n          </p>\n          <Link href=\"https://yaak.app/docs/using-yaak/request-inheritance\">Documentation</Link>\n        </EmptyStateText>\n      );\n    }\n    return <EmptyStateText>No authentication</EmptyStateText>;\n  }\n\n  if (inheritedAuth.authenticationType === \"none\") {\n    return <EmptyStateText>No authentication</EmptyStateText>;\n  }\n\n  const wasAuthInherited = inheritedAuth?.id !== model.id;\n  if (wasAuthInherited) {\n    const name = resolvedModelName(inheritedAuth);\n    const cta = inheritedAuth.model === \"workspace\" ? \"Workspace\" : name;\n    return (\n      <EmptyStateText>\n        <p>\n          Inherited from{\" \"}\n          <button\n            type=\"submit\"\n            className=\"underline hover:text-text\"\n            onClick={() => {\n              if (inheritedAuth.model === \"folder\") openFolderSettings(inheritedAuth.id, \"auth\");\n              else openWorkspaceSettings(\"auth\");\n            }}\n          >\n            {cta}\n          </button>\n        </p>\n      </EmptyStateText>\n    );\n  }\n\n  return (\n    <div className=\"h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-3\">\n      <div>\n        <HStack space={2} alignItems=\"start\">\n          <SegmentedControl\n            label=\"Enabled\"\n            hideLabel\n            name=\"enabled\"\n            value={\n              model.authentication.disabled === false || model.authentication.disabled == null\n                ? \"__TRUE__\"\n                : model.authentication.disabled === true\n                  ? \"__FALSE__\"\n                  : \"__DYNAMIC__\"\n            }\n            options={[\n              { label: \"Enabled\", value: \"__TRUE__\" },\n              { label: \"Disabled\", value: \"__FALSE__\" },\n              { label: \"Enabled when...\", value: \"__DYNAMIC__\" },\n            ]}\n            onChange={async (enabled) => {\n              let disabled: boolean | string;\n              if (enabled === \"__TRUE__\") {\n                disabled = false;\n              } else if (enabled === \"__FALSE__\") {\n                disabled = true;\n              } else {\n                disabled = \"\";\n              }\n              await handleChange({ ...model.authentication, disabled });\n            }}\n          />\n          {authConfig.data?.actions && authConfig.data.actions.length > 0 && (\n            <Dropdown\n              items={authConfig.data.actions.map(\n                (a): DropdownItem => ({\n                  label: a.label,\n                  leftSlot: a.icon ? <Icon icon={a.icon} /> : null,\n                  onSelect: () => a.call(model),\n                }),\n              )}\n            >\n              <IconButton\n                title=\"Authentication Actions\"\n                icon=\"settings\"\n                size=\"xs\"\n                className=\"!text-secondary\"\n              />\n            </Dropdown>\n          )}\n        </HStack>\n        {typeof model.authentication.disabled === \"string\" && (\n          <div className=\"mt-3\">\n            <AuthenticationDisabledInput\n              className=\"w-full\"\n              stateKey={`auth.${model.id}.dynamic`}\n              value={model.authentication.disabled}\n              onChange={(v) => handleChange({ ...model.authentication, disabled: v })}\n            />\n          </div>\n        )}\n      </div>\n      <DynamicForm\n        disabled={model.authentication.disabled === true}\n        autocompleteVariables\n        autocompleteFunctions\n        stateKey={`auth.${model.id}.${model.authenticationType}`}\n        inputs={authConfig.data?.args ?? []}\n        data={model.authentication}\n        onChange={handleChange}\n      />\n    </div>\n  );\n}\n\nfunction AuthenticationDisabledInput({\n  value,\n  onChange,\n  stateKey,\n  className,\n}: {\n  value: string;\n  onChange: InputProps[\"onChange\"];\n  stateKey: string;\n  className?: string;\n}) {\n  const rendered = useRenderTemplate({\n    template: value,\n    enabled: true,\n    purpose: \"preview\",\n    refreshKey: value,\n  });\n\n  return (\n    <Input\n      size=\"sm\"\n      className={className}\n      label=\"Dynamic Disabled\"\n      hideLabel\n      defaultValue={value}\n      placeholder=\"Enabled when this renders a non-empty value\"\n      rightSlot={\n        <div className=\"px-1 flex items-center\">\n          <div className=\"rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap\">\n            {rendered.isPending ? \"loading\" : rendered.data ? \"enabled\" : \"disabled\"}\n          </div>\n        </div>\n      }\n      autocompleteFunctions\n      autocompleteVariables\n      onChange={onChange}\n      stateKey={stateKey}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/HttpRequestLayout.tsx",
    "content": "import type { HttpRequest } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport type { CSSProperties } from \"react\";\nimport { useCurrentGraphQLSchema } from \"../hooks/useIntrospectGraphQL\";\nimport { workspaceLayoutAtom } from \"../lib/atoms\";\nimport type { SlotProps } from \"./core/SplitLayout\";\nimport { SplitLayout } from \"./core/SplitLayout\";\nimport { GraphQLDocsExplorer } from \"./graphql/GraphQLDocsExplorer\";\nimport { showGraphQLDocExplorerAtom } from \"./graphql/graphqlAtoms\";\nimport { HttpRequestPane } from \"./HttpRequestPane\";\nimport { HttpResponsePane } from \"./HttpResponsePane\";\n\ninterface Props {\n  activeRequest: HttpRequest;\n  style: CSSProperties;\n}\n\nexport function HttpRequestLayout({ activeRequest, style }: Props) {\n  const showGraphQLDocExplorer = useAtomValue(showGraphQLDocExplorerAtom);\n  const graphQLSchema = useCurrentGraphQLSchema(activeRequest);\n  const workspaceLayout = useAtomValue(workspaceLayoutAtom);\n\n  const requestResponseSplit = ({ style }: Pick<SlotProps, \"style\">) => (\n    <SplitLayout\n      name=\"http_layout\"\n      className=\"p-3 gap-1.5\"\n      style={style}\n      layout={workspaceLayout}\n      firstSlot={({ orientation, style }) => (\n        <HttpRequestPane\n          style={style}\n          activeRequest={activeRequest}\n          fullHeight={orientation === \"horizontal\"}\n        />\n      )}\n      secondSlot={({ style }) => (\n        <HttpResponsePane activeRequestId={activeRequest.id} style={style} />\n      )}\n    />\n  );\n\n  if (\n    activeRequest.bodyType === \"graphql\" &&\n    showGraphQLDocExplorer[activeRequest.id] !== undefined &&\n    graphQLSchema != null\n  ) {\n    return (\n      <SplitLayout\n        name=\"graphql_layout\"\n        defaultRatio={1 / 3}\n        firstSlot={requestResponseSplit}\n        secondSlot={({ style, orientation }) => (\n          <GraphQLDocsExplorer\n            requestId={activeRequest.id}\n            schema={graphQLSchema}\n            className={classNames(orientation === \"horizontal\" && \"!ml-0\")}\n            style={style}\n          />\n        )}\n      />\n    );\n  }\n\n  return requestResponseSplit({ style });\n}\n"
  },
  {
    "path": "src-web/components/HttpRequestPane.tsx",
    "content": "import type { HttpRequest } from \"@yaakapp-internal/models\";\nimport { patchModel } from \"@yaakapp-internal/models\";\nimport type { GenericCompletionOption } from \"@yaakapp-internal/plugins\";\nimport classNames from \"classnames\";\nimport { atom, useAtomValue } from \"jotai\";\nimport type { CSSProperties } from \"react\";\nimport { lazy, Suspense, useCallback, useMemo, useRef, useState } from \"react\";\nimport { activeRequestIdAtom } from \"../hooks/useActiveRequestId\";\nimport { allRequestsAtom } from \"../hooks/useAllRequests\";\nimport { useAuthTab } from \"../hooks/useAuthTab\";\nimport { useCancelHttpResponse } from \"../hooks/useCancelHttpResponse\";\nimport { useHeadersTab } from \"../hooks/useHeadersTab\";\nimport { useImportCurl } from \"../hooks/useImportCurl\";\nimport { useInheritedHeaders } from \"../hooks/useInheritedHeaders\";\nimport { usePinnedHttpResponse } from \"../hooks/usePinnedHttpResponse\";\nimport { useRequestEditor, useRequestEditorEvent } from \"../hooks/useRequestEditor\";\nimport { useRequestUpdateKey } from \"../hooks/useRequestUpdateKey\";\nimport { useSendAnyHttpRequest } from \"../hooks/useSendAnyHttpRequest\";\nimport { deepEqualAtom } from \"../lib/atoms\";\nimport { languageFromContentType } from \"../lib/contentType\";\nimport { generateId } from \"../lib/generateId\";\nimport {\n  BODY_TYPE_BINARY,\n  BODY_TYPE_FORM_MULTIPART,\n  BODY_TYPE_FORM_URLENCODED,\n  BODY_TYPE_GRAPHQL,\n  BODY_TYPE_JSON,\n  BODY_TYPE_NONE,\n  BODY_TYPE_OTHER,\n  BODY_TYPE_XML,\n  getContentTypeFromHeaders,\n} from \"../lib/model_util\";\nimport { prepareImportQuerystring } from \"../lib/prepareImportQuerystring\";\nimport { resolvedModelName } from \"../lib/resolvedModelName\";\nimport { showToast } from \"../lib/toast\";\nimport { BinaryFileEditor } from \"./BinaryFileEditor\";\nimport { ConfirmLargeRequestBody } from \"./ConfirmLargeRequestBody\";\nimport { CountBadge } from \"./core/CountBadge\";\nimport type { GenericCompletionConfig } from \"./core/Editor/genericCompletion\";\nimport { Editor } from \"./core/Editor/LazyEditor\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport type { Pair } from \"./core/PairEditor\";\nimport { PlainInput } from \"./core/PlainInput\";\nimport type { TabItem, TabsRef } from \"./core/Tabs/Tabs\";\nimport { setActiveTab, TabContent, Tabs } from \"./core/Tabs/Tabs\";\nimport { EmptyStateText } from \"./EmptyStateText\";\nimport { FormMultipartEditor } from \"./FormMultipartEditor\";\nimport { FormUrlencodedEditor } from \"./FormUrlencodedEditor\";\nimport { HeadersEditor } from \"./HeadersEditor\";\nimport { HttpAuthenticationEditor } from \"./HttpAuthenticationEditor\";\nimport { JsonBodyEditor } from \"./JsonBodyEditor\";\nimport { MarkdownEditor } from \"./MarkdownEditor\";\nimport { RequestMethodDropdown } from \"./RequestMethodDropdown\";\nimport { UrlBar } from \"./UrlBar\";\nimport { UrlParametersEditor } from \"./UrlParameterEditor\";\n\nconst GraphQLEditor = lazy(() =>\n  import(\"./graphql/GraphQLEditor\").then((m) => ({ default: m.GraphQLEditor })),\n);\n\ninterface Props {\n  style: CSSProperties;\n  fullHeight: boolean;\n  className?: string;\n  activeRequest: HttpRequest;\n}\n\nconst TAB_BODY = \"body\";\nconst TAB_PARAMS = \"params\";\nconst TAB_HEADERS = \"headers\";\nconst TAB_AUTH = \"auth\";\nconst TAB_DESCRIPTION = \"description\";\nconst TABS_STORAGE_KEY = \"http_request_tabs\";\n\nconst nonActiveRequestUrlsAtom = atom((get) => {\n  const activeRequestId = get(activeRequestIdAtom);\n  const requests = get(allRequestsAtom);\n  return requests\n    .filter((r) => r.id !== activeRequestId)\n    .map((r): GenericCompletionOption => ({ type: \"constant\", label: r.url }));\n});\n\nconst memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);\n\nexport function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {\n  const activeRequestId = activeRequest.id;\n  const tabsRef = useRef<TabsRef>(null);\n  const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);\n  const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);\n  const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();\n  const contentType = getContentTypeFromHeaders(activeRequest.headers);\n  const authTab = useAuthTab(TAB_AUTH, activeRequest);\n  const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);\n  const inheritedHeaders = useInheritedHeaders(activeRequest);\n\n  // Listen for event to focus the params tab (e.g., when clicking a :param in the URL)\n  useRequestEditorEvent(\n    \"request_pane.focus_tab\",\n    () => {\n      tabsRef.current?.setActiveTab(TAB_PARAMS);\n    },\n    [],\n  );\n\n  const handleContentTypeChange = useCallback(\n    async (contentType: string | null, patch: Partial<Omit<HttpRequest, \"headers\">> = {}) => {\n      if (activeRequest == null) {\n        console.error(\"Failed to get active request to update\", activeRequest);\n        return;\n      }\n\n      const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== \"content-type\");\n\n      if (contentType != null) {\n        headers.push({\n          name: \"Content-Type\",\n          value: contentType,\n          enabled: true,\n          id: generateId(),\n        });\n      }\n      await patchModel(activeRequest, { ...patch, headers });\n\n      // Force update header editor so any changed headers are reflected\n      setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);\n    },\n    [activeRequest],\n  );\n\n  const { urlParameterPairs, urlParametersKey } = useMemo(() => {\n    const placeholderNames = Array.from(activeRequest.url.matchAll(/\\/(:[^/]+)/g)).map(\n      (m) => m[1] ?? \"\",\n    );\n    const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);\n    const items: Pair[] = [...nonEmptyParameters];\n    for (const name of placeholderNames) {\n      const item = items.find((p) => p.name === name);\n      if (item) {\n        item.readOnlyName = true;\n      } else {\n        items.push({ name, value: \"\", enabled: true, readOnlyName: true, id: generateId() });\n      }\n    }\n    return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(\",\") };\n  }, [activeRequest.url, activeRequest.urlParameters]);\n\n  let numParams = 0;\n  if (\n    activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ||\n    activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART\n  ) {\n    numParams = Array.isArray(activeRequest.body?.form)\n      ? activeRequest.body.form.filter((p) => p.name).length\n      : 0;\n  }\n\n  const tabs = useMemo<TabItem[]>(\n    () => [\n      {\n        value: TAB_BODY,\n        rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,\n        options: {\n          value: activeRequest.bodyType,\n          items: [\n            { type: \"separator\", label: \"Form Data\" },\n            { label: \"Url Encoded\", value: BODY_TYPE_FORM_URLENCODED },\n            { label: \"Multi-Part\", value: BODY_TYPE_FORM_MULTIPART },\n            { type: \"separator\", label: \"Text Content\" },\n            { label: \"GraphQL\", value: BODY_TYPE_GRAPHQL },\n            { label: \"JSON\", value: BODY_TYPE_JSON },\n            { label: \"XML\", value: BODY_TYPE_XML },\n            {\n              label: \"Other\",\n              value: BODY_TYPE_OTHER,\n              shortLabel: nameOfContentTypeOr(contentType, \"Other\"),\n            },\n            { type: \"separator\", label: \"Other\" },\n            { label: \"Binary File\", value: BODY_TYPE_BINARY },\n            { label: \"No Body\", shortLabel: \"Body\", value: BODY_TYPE_NONE },\n          ],\n          onChange: async (bodyType) => {\n            if (bodyType === activeRequest.bodyType) return;\n\n            const showMethodToast = (newMethod: string) => {\n              if (activeRequest.method.toLowerCase() === newMethod.toLowerCase()) return;\n              showToast({\n                id: \"switched-method\",\n                message: (\n                  <>\n                    Request method switched to <InlineCode>POST</InlineCode>\n                  </>\n                ),\n              });\n            };\n\n            const patch: Partial<HttpRequest> = { bodyType };\n            let newContentType: string | null | undefined;\n            if (bodyType === BODY_TYPE_NONE) {\n              newContentType = null;\n            } else if (\n              bodyType === BODY_TYPE_FORM_URLENCODED ||\n              bodyType === BODY_TYPE_FORM_MULTIPART ||\n              bodyType === BODY_TYPE_JSON ||\n              bodyType === BODY_TYPE_OTHER ||\n              bodyType === BODY_TYPE_XML\n            ) {\n              const isDefaultishRequest =\n                activeRequest.bodyType === BODY_TYPE_NONE &&\n                activeRequest.method.toLowerCase() === \"get\";\n              const requiresPost = bodyType === BODY_TYPE_FORM_MULTIPART;\n              if (isDefaultishRequest || requiresPost) {\n                patch.method = \"POST\";\n                showMethodToast(patch.method);\n              }\n              newContentType = bodyType === BODY_TYPE_OTHER ? \"text/plain\" : bodyType;\n            } else if (bodyType === BODY_TYPE_GRAPHQL) {\n              patch.method = \"POST\";\n              newContentType = \"application/json\";\n              showMethodToast(patch.method);\n            }\n\n            if (newContentType !== undefined) {\n              await handleContentTypeChange(newContentType, patch);\n            } else {\n              await patchModel(activeRequest, patch);\n            }\n          },\n        },\n      },\n      {\n        value: TAB_PARAMS,\n        rightSlot: <CountBadge count={urlParameterPairs.length} />,\n        label: \"Params\",\n      },\n      ...headersTab,\n      ...authTab,\n      {\n        value: TAB_DESCRIPTION,\n        label: \"Info\",\n      },\n    ],\n    [\n      activeRequest,\n      authTab,\n      contentType,\n      handleContentTypeChange,\n      headersTab,\n      numParams,\n      urlParameterPairs.length,\n    ],\n  );\n\n  const { mutate: sendRequest } = useSendAnyHttpRequest();\n  const { activeResponse } = usePinnedHttpResponse(activeRequestId);\n  const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);\n  const updateKey = useRequestUpdateKey(activeRequestId);\n  const { mutate: importCurl } = useImportCurl();\n\n  const handleBodyChange = useCallback(\n    (body: HttpRequest[\"body\"]) => patchModel(activeRequest, { body }),\n    [activeRequest],\n  );\n\n  const handleBodyTextChange = useCallback(\n    (text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),\n    [activeRequest],\n  );\n\n  const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);\n\n  const autocomplete: GenericCompletionConfig = useMemo(\n    () => ({\n      minMatch: 3,\n      options:\n        autocompleteUrls.length > 0\n          ? autocompleteUrls\n          : [\n              { label: \"http://\", type: \"constant\" },\n              { label: \"https://\", type: \"constant\" },\n            ],\n    }),\n    [autocompleteUrls],\n  );\n\n  const handlePaste = useCallback(\n    async (e: ClipboardEvent, text: string) => {\n      if (text.startsWith(\"curl \")) {\n        importCurl({ overwriteRequestId: activeRequestId, command: text });\n      } else {\n        const patch = prepareImportQuerystring(text);\n        if (patch != null) {\n          e.preventDefault(); // Prevent input onChange\n\n          await patchModel(activeRequest, patch);\n          await setActiveTab({\n            storageKey: TABS_STORAGE_KEY,\n            activeTabKey: activeRequestId,\n            value: TAB_PARAMS,\n          });\n\n          // Wait for request to update, then refresh the UI\n          // TODO: Somehow make this deterministic\n          setTimeout(() => {\n            forceUrlRefresh();\n            forceParamsRefresh();\n          }, 100);\n        }\n      }\n    },\n    [activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],\n  );\n  const handleSend = useCallback(\n    () => sendRequest(activeRequest.id ?? null),\n    [activeRequest.id, sendRequest],\n  );\n\n  const handleUrlChange = useCallback(\n    (url: string) => patchModel(activeRequest, { url }),\n    [activeRequest],\n  );\n\n  return (\n    <div\n      style={style}\n      className={classNames(className, \"h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1\")}\n    >\n      {activeRequest && (\n        <>\n          <UrlBar\n            stateKey={`url.${activeRequest.id}`}\n            key={forceUpdateKey + urlKey}\n            url={activeRequest.url}\n            placeholder=\"https://example.com\"\n            onPasteOverwrite={handlePaste}\n            autocomplete={autocomplete}\n            onSend={handleSend}\n            onCancel={cancelResponse}\n            onUrlChange={handleUrlChange}\n            leftSlot={\n              <div className=\"py-0.5\">\n                <RequestMethodDropdown request={activeRequest} className=\"ml-0.5 !h-full\" />\n              </div>\n            }\n            forceUpdateKey={updateKey}\n            isLoading={activeResponse != null && activeResponse.state !== \"closed\"}\n          />\n          <Tabs\n            ref={tabsRef}\n            label=\"Request\"\n            tabs={tabs}\n            tabListClassName=\"mt-1 -mb-1.5\"\n            storageKey={TABS_STORAGE_KEY}\n            activeTabKey={activeRequestId}\n          >\n            <TabContent value={TAB_AUTH}>\n              <HttpAuthenticationEditor model={activeRequest} />\n            </TabContent>\n            <TabContent value={TAB_HEADERS}>\n              <HeadersEditor\n                inheritedHeaders={inheritedHeaders}\n                forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}\n                headers={activeRequest.headers}\n                stateKey={`headers.${activeRequest.id}`}\n                onChange={(headers) => patchModel(activeRequest, { headers })}\n              />\n            </TabContent>\n            <TabContent value={TAB_PARAMS}>\n              <UrlParametersEditor\n                stateKey={`params.${activeRequest.id}`}\n                forceUpdateKey={forceUpdateKey + urlParametersKey}\n                pairs={urlParameterPairs}\n                onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}\n              />\n            </TabContent>\n            <TabContent value={TAB_BODY}>\n              <ConfirmLargeRequestBody request={activeRequest}>\n                {activeRequest.bodyType === BODY_TYPE_JSON ? (\n                  <JsonBodyEditor\n                    forceUpdateKey={forceUpdateKey}\n                    heightMode={fullHeight ? \"full\" : \"auto\"}\n                    request={activeRequest}\n                  />\n                ) : activeRequest.bodyType === BODY_TYPE_XML ? (\n                  <Editor\n                    forceUpdateKey={forceUpdateKey}\n                    autocompleteFunctions\n                    autocompleteVariables\n                    placeholder=\"...\"\n                    heightMode={fullHeight ? \"full\" : \"auto\"}\n                    defaultValue={`${activeRequest.body?.text ?? \"\"}`}\n                    language=\"xml\"\n                    onChange={handleBodyTextChange}\n                    stateKey={`xml.${activeRequest.id}`}\n                  />\n                ) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (\n                  <Suspense>\n                    <GraphQLEditor\n                      forceUpdateKey={forceUpdateKey}\n                      baseRequest={activeRequest}\n                      request={activeRequest}\n                      onChange={handleBodyChange}\n                    />\n                  </Suspense>\n                ) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? (\n                  <FormUrlencodedEditor\n                    forceUpdateKey={forceUpdateKey}\n                    request={activeRequest}\n                    onChange={handleBodyChange}\n                  />\n                ) : activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ? (\n                  <FormMultipartEditor\n                    forceUpdateKey={forceUpdateKey}\n                    request={activeRequest}\n                    onChange={handleBodyChange}\n                  />\n                ) : activeRequest.bodyType === BODY_TYPE_BINARY ? (\n                  <BinaryFileEditor\n                    requestId={activeRequest.id}\n                    contentType={contentType}\n                    body={activeRequest.body}\n                    onChange={(body) => patchModel(activeRequest, { body })}\n                    onChangeContentType={handleContentTypeChange}\n                  />\n                ) : typeof activeRequest.bodyType === \"string\" ? (\n                  <Editor\n                    forceUpdateKey={forceUpdateKey}\n                    autocompleteFunctions\n                    autocompleteVariables\n                    language={languageFromContentType(contentType)}\n                    placeholder=\"...\"\n                    heightMode={fullHeight ? \"full\" : \"auto\"}\n                    defaultValue={`${activeRequest.body?.text ?? \"\"}`}\n                    onChange={handleBodyTextChange}\n                    stateKey={`other.${activeRequest.id}`}\n                  />\n                ) : (\n                  <EmptyStateText>No Body</EmptyStateText>\n                )}\n              </ConfirmLargeRequestBody>\n            </TabContent>\n            <TabContent value={TAB_DESCRIPTION}>\n              <div className=\"grid grid-rows-[auto_minmax(0,1fr)] h-full\">\n                <PlainInput\n                  label=\"Request Name\"\n                  hideLabel\n                  forceUpdateKey={updateKey}\n                  defaultValue={activeRequest.name}\n                  className=\"font-sans !text-xl !px-0\"\n                  containerClassName=\"border-0\"\n                  placeholder={resolvedModelName(activeRequest)}\n                  onChange={(name) => patchModel(activeRequest, { name })}\n                />\n                <MarkdownEditor\n                  name=\"request-description\"\n                  placeholder=\"Request description\"\n                  defaultValue={activeRequest.description}\n                  stateKey={`description.${activeRequest.id}`}\n                  forceUpdateKey={updateKey}\n                  onChange={(description) => patchModel(activeRequest, { description })}\n                />\n              </div>\n            </TabContent>\n          </Tabs>\n        </>\n      )}\n    </div>\n  );\n}\n\nfunction nameOfContentTypeOr(contentType: string | null, fallback: string) {\n  const language = languageFromContentType(contentType);\n  if (language === \"markdown\") {\n    return \"Markdown\";\n  }\n  return fallback;\n}\n"
  },
  {
    "path": "src-web/components/HttpResponsePane.tsx",
    "content": "import type { HttpResponse, HttpResponseEvent } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport type { ComponentType, CSSProperties } from \"react\";\nimport { lazy, Suspense, useMemo } from \"react\";\nimport { useCancelHttpResponse } from \"../hooks/useCancelHttpResponse\";\nimport { useHttpResponseEvents } from \"../hooks/useHttpResponseEvents\";\nimport { usePinnedHttpResponse } from \"../hooks/usePinnedHttpResponse\";\nimport { useResponseBodyBytes, useResponseBodyText } from \"../hooks/useResponseBodyText\";\nimport { useResponseViewMode } from \"../hooks/useResponseViewMode\";\nimport { useTimelineViewMode } from \"../hooks/useTimelineViewMode\";\nimport { getMimeTypeFromContentType } from \"../lib/contentType\";\nimport { getContentTypeFromHeaders, getCookieCounts } from \"../lib/model_util\";\nimport { ConfirmLargeResponse } from \"./ConfirmLargeResponse\";\nimport { ConfirmLargeResponseRequest } from \"./ConfirmLargeResponseRequest\";\nimport { Banner } from \"./core/Banner\";\nimport { Button } from \"./core/Button\";\nimport { CountBadge } from \"./core/CountBadge\";\nimport { HotkeyList } from \"./core/HotkeyList\";\nimport { HttpResponseDurationTag } from \"./core/HttpResponseDurationTag\";\nimport { HttpStatusTag } from \"./core/HttpStatusTag\";\nimport { Icon } from \"./core/Icon\";\nimport { LoadingIcon } from \"./core/LoadingIcon\";\nimport { PillButton } from \"./core/PillButton\";\nimport { SizeTag } from \"./core/SizeTag\";\nimport { HStack, VStack } from \"./core/Stacks\";\nimport type { TabItem } from \"./core/Tabs/Tabs\";\nimport { TabContent, Tabs } from \"./core/Tabs/Tabs\";\nimport { Tooltip } from \"./core/Tooltip\";\nimport { EmptyStateText } from \"./EmptyStateText\";\nimport { ErrorBoundary } from \"./ErrorBoundary\";\nimport { HttpResponseTimeline } from \"./HttpResponseTimeline\";\nimport { RecentHttpResponsesDropdown } from \"./RecentHttpResponsesDropdown\";\nimport { RequestBodyViewer } from \"./RequestBodyViewer\";\nimport { ResponseCookies } from \"./ResponseCookies\";\nimport { ResponseHeaders } from \"./ResponseHeaders\";\nimport { AudioViewer } from \"./responseViewers/AudioViewer\";\nimport { CsvViewer } from \"./responseViewers/CsvViewer\";\nimport { EventStreamViewer } from \"./responseViewers/EventStreamViewer\";\nimport { HTMLOrTextViewer } from \"./responseViewers/HTMLOrTextViewer\";\nimport { ImageViewer } from \"./responseViewers/ImageViewer\";\nimport { MultipartViewer } from \"./responseViewers/MultipartViewer\";\nimport { SvgViewer } from \"./responseViewers/SvgViewer\";\nimport { VideoViewer } from \"./responseViewers/VideoViewer\";\n\nconst PdfViewer = lazy(() =>\n  import(\"./responseViewers/PdfViewer\").then((m) => ({ default: m.PdfViewer })),\n);\n\ninterface Props {\n  style?: CSSProperties;\n  className?: string;\n  activeRequestId: string;\n}\n\nconst TAB_BODY = \"body\";\nconst TAB_REQUEST = \"request\";\nconst TAB_HEADERS = \"headers\";\nconst TAB_COOKIES = \"cookies\";\nconst TAB_TIMELINE = \"timeline\";\n\nexport type TimelineViewMode = \"timeline\" | \"text\";\n\ninterface RedirectDropWarning {\n  droppedBodyCount: number;\n  droppedHeaders: string[];\n}\n\nexport function HttpResponsePane({ style, className, activeRequestId }: Props) {\n  const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);\n  const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);\n  const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();\n  const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);\n  const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;\n\n  const responseEvents = useHttpResponseEvents(activeResponse);\n  const redirectDropWarning = useMemo(\n    () => getRedirectDropWarning(responseEvents.data),\n    [responseEvents.data],\n  );\n  const shouldShowRedirectDropWarning =\n    activeResponse?.state === \"closed\" && redirectDropWarning != null;\n\n  const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);\n\n  const tabs = useMemo<TabItem[]>(\n    () => [\n      {\n        value: TAB_BODY,\n        label: \"Response\",\n        options: {\n          value: viewMode,\n          onChange: setViewMode,\n          items: [\n            { label: \"Response\", value: \"pretty\" },\n            ...(mimeType?.startsWith(\"image\")\n              ? []\n              : [{ label: \"Response (Raw)\", shortLabel: \"Raw\", value: \"raw\" }]),\n          ],\n        },\n      },\n      {\n        value: TAB_REQUEST,\n        label: \"Request\",\n        rightSlot:\n          (activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,\n      },\n      {\n        value: TAB_HEADERS,\n        label: \"Headers\",\n        rightSlot: (\n          <CountBadge\n            count={activeResponse?.requestHeaders.length ?? 0}\n            count2={activeResponse?.headers.length ?? 0}\n            showZero\n          />\n        ),\n      },\n      {\n        value: TAB_COOKIES,\n        label: \"Cookies\",\n        rightSlot:\n          cookieCounts.sent > 0 || cookieCounts.received > 0 ? (\n            <CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />\n          ) : null,\n      },\n      {\n        value: TAB_TIMELINE,\n        rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,\n        options: {\n          value: timelineViewMode,\n          onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? \"timeline\"),\n          items: [\n            { label: \"Timeline\", value: \"timeline\" },\n            { label: \"Timeline (Text)\", shortLabel: \"Timeline\", value: \"text\" },\n          ],\n        },\n      },\n    ],\n    [\n      activeResponse?.headers,\n      activeResponse?.requestContentLength,\n      activeResponse?.requestHeaders.length,\n      cookieCounts.sent,\n      cookieCounts.received,\n      mimeType,\n      responseEvents.data?.length,\n      setViewMode,\n      viewMode,\n      timelineViewMode,\n      setTimelineViewMode,\n    ],\n  );\n\n  const cancel = useCancelHttpResponse(activeResponse?.id ?? null);\n\n  return (\n    <div\n      style={style}\n      className={classNames(\n        className,\n        \"x-theme-responsePane\",\n        \"max-h-full h-full\",\n        \"bg-surface rounded-md border border-border-subtle overflow-hidden\",\n        \"relative\",\n      )}\n    >\n      {activeResponse == null ? (\n        <HotkeyList hotkeys={[\"request.send\", \"model.create\", \"sidebar.focus\", \"url_bar.focus\"]} />\n      ) : (\n        <div className=\"h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1\">\n          <HStack\n            className={classNames(\n              \"text-text-subtle w-full flex-shrink-0\",\n              // Remove a bit of space because the tabs have lots too\n              \"-mb-1.5\",\n            )}\n          >\n            {activeResponse && (\n              <div\n                className={classNames(\n                  \"grid grid-cols-[auto_minmax(4rem,1fr)_auto]\",\n                  \"cursor-default select-none\",\n                  \"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars\",\n                )}\n              >\n                <HStack space={2} className=\"w-full flex-shrink-0\">\n                  {activeResponse.state !== \"closed\" && <LoadingIcon size=\"sm\" />}\n                  <HttpStatusTag showReason response={activeResponse} />\n                  <span>&bull;</span>\n                  <HttpResponseDurationTag response={activeResponse} />\n                  <span>&bull;</span>\n                  <SizeTag\n                    contentLength={activeResponse.contentLength ?? 0}\n                    contentLengthCompressed={activeResponse.contentLengthCompressed}\n                  />\n                </HStack>\n                {shouldShowRedirectDropWarning ? (\n                  <Tooltip\n                    tabIndex={0}\n                    className=\"my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden\"\n                    content={\n                      <VStack alignItems=\"start\" space={1} className=\"text-xs\">\n                        <span className=\"font-medium text-warning\">\n                          Redirect changed this request\n                        </span>\n                        {redirectDropWarning.droppedBodyCount > 0 && (\n                          <span>\n                            Body dropped on {redirectDropWarning.droppedBodyCount}{\" \"}\n                            {redirectDropWarning.droppedBodyCount === 1\n                              ? \"redirect hop\"\n                              : \"redirect hops\"}\n                          </span>\n                        )}\n                        {redirectDropWarning.droppedHeaders.length > 0 && (\n                          <span>\n                            Headers dropped:{\" \"}\n                            <span className=\"font-mono\">\n                              {redirectDropWarning.droppedHeaders.join(\", \")}\n                            </span>\n                          </span>\n                        )}\n                        <span className=\"text-text-subtle\">See Timeline for details.</span>\n                      </VStack>\n                    }\n                  >\n                    <span className=\"inline-flex min-w-0\">\n                      <PillButton\n                        color=\"warning\"\n                        className=\"font-sans text-sm !flex-shrink max-w-full\"\n                        innerClassName=\"flex items-center\"\n                        leftSlot={<Icon icon=\"alert_triangle\" size=\"xs\" color=\"warning\" />}\n                      >\n                        <span className=\"truncate\">\n                          {getRedirectWarningLabel(redirectDropWarning)}\n                        </span>\n                      </PillButton>\n                    </span>\n                  </Tooltip>\n                ) : (\n                  <span />\n                )}\n                <div className=\"justify-self-end flex-shrink-0\">\n                  <RecentHttpResponsesDropdown\n                    responses={responses}\n                    activeResponse={activeResponse}\n                    onPinnedResponseId={setPinnedResponseId}\n                  />\n                </div>\n              </div>\n            )}\n          </HStack>\n\n          <div className=\"overflow-hidden flex flex-col min-h-0\">\n            {activeResponse?.error && (\n              <Banner color=\"danger\" className=\"mx-3 mt-1 flex-shrink-0\">\n                {activeResponse.error}\n              </Banner>\n            )}\n            {/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}\n            <Tabs\n              tabs={tabs}\n              label=\"Response\"\n              className=\"ml-3 mr-3 mb-3 min-h-0 flex-1\"\n              tabListClassName=\"mt-0.5 -mb-1.5\"\n              storageKey=\"http_response_tabs\"\n              activeTabKey={activeRequestId}\n            >\n              <TabContent value={TAB_BODY}>\n                <ErrorBoundary name=\"Http Response Viewer\">\n                  <Suspense>\n                    <ConfirmLargeResponse response={activeResponse}>\n                      {activeResponse.state === \"initialized\" ? (\n                        <EmptyStateText>\n                          <VStack space={3}>\n                            <HStack space={3}>\n                              <LoadingIcon className=\"text-text-subtlest\" />\n                              Sending Request\n                            </HStack>\n                            <Button size=\"sm\" variant=\"border\" onClick={() => cancel.mutate()}>\n                              Cancel\n                            </Button>\n                          </VStack>\n                        </EmptyStateText>\n                      ) : activeResponse.state === \"closed\" &&\n                        (activeResponse.contentLength ?? 0) === 0 ? (\n                        <EmptyStateText>Empty</EmptyStateText>\n                      ) : mimeType?.match(/^text\\/event-stream/i) && viewMode === \"pretty\" ? (\n                        <EventStreamViewer response={activeResponse} />\n                      ) : mimeType?.match(/^image\\/svg/) ? (\n                        <HttpSvgViewer response={activeResponse} />\n                      ) : mimeType?.match(/^image/i) ? (\n                        <EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />\n                      ) : mimeType?.match(/^audio/i) ? (\n                        <EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />\n                      ) : mimeType?.match(/^video/i) ? (\n                        <EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />\n                      ) : mimeType?.match(/^multipart/i) && viewMode === \"pretty\" ? (\n                        <HttpMultipartViewer response={activeResponse} />\n                      ) : mimeType?.match(/pdf/i) ? (\n                        <EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />\n                      ) : mimeType?.match(/csv|tab-separated/i) && viewMode === \"pretty\" ? (\n                        <HttpCsvViewer className=\"pb-2\" response={activeResponse} />\n                      ) : (\n                        <HTMLOrTextViewer\n                          textViewerClassName=\"-mr-2 bg-surface\" // Pull to the right\n                          response={activeResponse}\n                          pretty={viewMode === \"pretty\"}\n                        />\n                      )}\n                    </ConfirmLargeResponse>\n                  </Suspense>\n                </ErrorBoundary>\n              </TabContent>\n              <TabContent value={TAB_REQUEST}>\n                <ConfirmLargeResponseRequest response={activeResponse}>\n                  <RequestBodyViewer response={activeResponse} />\n                </ConfirmLargeResponseRequest>\n              </TabContent>\n              <TabContent value={TAB_HEADERS}>\n                <ResponseHeaders response={activeResponse} />\n              </TabContent>\n              <TabContent value={TAB_COOKIES}>\n                <ResponseCookies response={activeResponse} />\n              </TabContent>\n              <TabContent value={TAB_TIMELINE}>\n                <HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />\n              </TabContent>\n            </Tabs>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction getRedirectDropWarning(\n  events: HttpResponseEvent[] | undefined,\n): RedirectDropWarning | null {\n  if (events == null || events.length === 0) return null;\n\n  let droppedBodyCount = 0;\n  const droppedHeaders = new Set<string>();\n  for (const e of events) {\n    const event = e.event;\n    if (event.type !== \"redirect\") {\n      continue;\n    }\n\n    if (event.dropped_body) {\n      droppedBodyCount += 1;\n    }\n    for (const headerName of event.dropped_headers ?? []) {\n      pushHeaderName(droppedHeaders, headerName);\n    }\n  }\n\n  if (droppedBodyCount === 0 && droppedHeaders.size === 0) {\n    return null;\n  }\n\n  return {\n    droppedBodyCount,\n    droppedHeaders: Array.from(droppedHeaders).sort(),\n  };\n}\n\nfunction pushHeaderName(headers: Set<string>, headerName: string): void {\n  const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase());\n  if (existing == null) {\n    headers.add(headerName);\n  }\n}\n\nfunction getRedirectWarningLabel(warning: RedirectDropWarning): string {\n  if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {\n    return \"Dropped body and headers\";\n  }\n  if (warning.droppedBodyCount > 0) {\n    return \"Dropped body\";\n  }\n  return \"Dropped headers\";\n}\n\nfunction EnsureCompleteResponse({\n  response,\n  Component,\n}: {\n  response: HttpResponse;\n  Component: ComponentType<{ bodyPath: string }>;\n}) {\n  if (response.bodyPath === null) {\n    return <div>Empty response body</div>;\n  }\n\n  // Wait until the response has been fully-downloaded\n  if (response.state !== \"closed\") {\n    return (\n      <EmptyStateText>\n        <LoadingIcon />\n      </EmptyStateText>\n    );\n  }\n\n  return <Component bodyPath={response.bodyPath} />;\n}\n\nfunction HttpSvgViewer({ response }: { response: HttpResponse }) {\n  const body = useResponseBodyText({ response, filter: null });\n\n  if (!body.data) return null;\n\n  return <SvgViewer text={body.data} />;\n}\n\nfunction HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {\n  const body = useResponseBodyText({ response, filter: null });\n\n  return <CsvViewer text={body.data ?? null} className={className} />;\n}\n\nfunction HttpMultipartViewer({ response }: { response: HttpResponse }) {\n  const body = useResponseBodyBytes({ response });\n\n  if (body.data == null) return null;\n\n  const contentTypeHeader = getContentTypeFromHeaders(response.headers);\n  const boundary = contentTypeHeader?.split(\"boundary=\")[1] ?? \"unknown\";\n\n  return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;\n}\n"
  },
  {
    "path": "src-web/components/HttpResponseTimeline.tsx",
    "content": "import type {\n  HttpResponse,\n  HttpResponseEvent,\n  HttpResponseEventData,\n} from \"@yaakapp-internal/models\";\nimport { type ReactNode, useMemo, useState } from \"react\";\nimport { useHttpResponseEvents } from \"../hooks/useHttpResponseEvents\";\nimport { Editor } from \"./core/Editor/LazyEditor\";\nimport { type EventDetailAction, EventDetailHeader, EventViewer } from \"./core/EventViewer\";\nimport { EventViewerRow } from \"./core/EventViewerRow\";\nimport { HttpStatusTagRaw } from \"./core/HttpStatusTag\";\nimport { Icon, type IconProps } from \"./core/Icon\";\nimport { KeyValueRow, KeyValueRows } from \"./core/KeyValueRow\";\nimport type { TimelineViewMode } from \"./HttpResponsePane\";\n\ninterface Props {\n  response: HttpResponse;\n  viewMode: TimelineViewMode;\n}\n\nexport function HttpResponseTimeline({ response, viewMode }: Props) {\n  return <Inner key={response.id} response={response} viewMode={viewMode} />;\n}\n\nfunction Inner({ response, viewMode }: Props) {\n  const [showRaw, setShowRaw] = useState(false);\n  const { data: events, error, isLoading } = useHttpResponseEvents(response);\n\n  // Generate plain text representation of all events (with prefixes for timeline view)\n  const plainText = useMemo(() => {\n    if (!events || events.length === 0) return \"\";\n    return events.map((event) => formatEventText(event.event, true)).join(\"\\n\");\n  }, [events]);\n\n  // Plain text view - show all events as text in an editor\n  if (viewMode === \"text\") {\n    if (isLoading) {\n      return <div className=\"p-4 text-text-subtlest\">Loading events...</div>;\n    } else if (error) {\n      return <div className=\"p-4 text-danger\">{String(error)}</div>;\n    } else if (!events || events.length === 0) {\n      return <div className=\"p-4 text-text-subtlest\">No events recorded</div>;\n    } else {\n      return (\n        <Editor language=\"timeline\" defaultValue={plainText} readOnly stateKey={null} hideGutter />\n      );\n    }\n  }\n\n  return (\n    <EventViewer\n      events={events ?? []}\n      getEventKey={(event) => event.id}\n      error={error ? String(error) : null}\n      isLoading={isLoading}\n      loadingMessage=\"Loading events...\"\n      emptyMessage=\"No events recorded\"\n      splitLayoutName=\"http_response_events\"\n      defaultRatio={0.25}\n      renderRow={({ event, isActive, onClick }) => {\n        const display = getEventDisplay(event.event);\n        return (\n          <EventViewerRow\n            isActive={isActive}\n            onClick={onClick}\n            icon={<Icon color={display.color} icon={display.icon} size=\"sm\" />}\n            content={display.summary}\n            timestamp={event.createdAt}\n          />\n        );\n      }}\n      renderDetail={({ event, onClose }) => (\n        <EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} onClose={onClose} />\n      )}\n    />\n  );\n}\n\nfunction formatBytes(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nfunction EventDetails({\n  event,\n  showRaw,\n  setShowRaw,\n  onClose,\n}: {\n  event: HttpResponseEvent;\n  showRaw: boolean;\n  setShowRaw: (v: boolean) => void;\n  onClose: () => void;\n}) {\n  const { label } = getEventDisplay(event.event);\n  const e = event.event;\n\n  const actions: EventDetailAction[] = [\n    {\n      key: \"toggle-raw\",\n      label: showRaw ? \"Formatted\" : \"Text\",\n      onClick: () => setShowRaw(!showRaw),\n    },\n  ];\n\n  // Determine the title based on event type\n  const title = (() => {\n    switch (e.type) {\n      case \"header_up\":\n        return \"Header Sent\";\n      case \"header_down\":\n        return \"Header Received\";\n      case \"send_url\":\n        return \"Request\";\n      case \"receive_url\":\n        return \"Response\";\n      case \"redirect\":\n        return \"Redirect\";\n      case \"setting\":\n        return \"Apply Setting\";\n      case \"chunk_sent\":\n        return \"Data Sent\";\n      case \"chunk_received\":\n        return \"Data Received\";\n      case \"dns_resolved\":\n        return e.overridden ? \"DNS Override\" : \"DNS Resolution\";\n      default:\n        return label;\n    }\n  })();\n\n  // Render content based on view mode and event type\n  const renderContent = () => {\n    // Raw view - show plaintext representation (without prefix)\n    if (showRaw) {\n      const rawText = formatEventText(event.event, false);\n      return <Editor language=\"text\" defaultValue={rawText} readOnly stateKey={null} hideGutter />;\n    }\n\n    // Headers - show name and value\n    if (e.type === \"header_up\" || e.type === \"header_down\") {\n      return (\n        <KeyValueRows>\n          <KeyValueRow label=\"Header\">{e.name}</KeyValueRow>\n          <KeyValueRow label=\"Value\">{e.value}</KeyValueRow>\n        </KeyValueRows>\n      );\n    }\n\n    // Request URL - show all URL parts separately\n    if (e.type === \"send_url\") {\n      const auth = e.username || e.password ? `${e.username}:${e.password}@` : \"\";\n      const isDefaultPort =\n        (e.scheme === \"http\" && e.port === 80) || (e.scheme === \"https\" && e.port === 443);\n      const portStr = isDefaultPort ? \"\" : `:${e.port}`;\n      const query = e.query ? `?${e.query}` : \"\";\n      const fragment = e.fragment ? `#${e.fragment}` : \"\";\n      const fullUrl = `${e.scheme}://${auth}${e.host}${portStr}${e.path}${query}${fragment}`;\n      return (\n        <KeyValueRows>\n          <KeyValueRow label=\"URL\">{fullUrl}</KeyValueRow>\n          <KeyValueRow label=\"Method\">{e.method}</KeyValueRow>\n          <KeyValueRow label=\"Scheme\">{e.scheme}</KeyValueRow>\n          {e.username ? <KeyValueRow label=\"Username\">{e.username}</KeyValueRow> : null}\n          {e.password ? <KeyValueRow label=\"Password\">{e.password}</KeyValueRow> : null}\n          <KeyValueRow label=\"Host\">{e.host}</KeyValueRow>\n          {!isDefaultPort ? <KeyValueRow label=\"Port\">{e.port}</KeyValueRow> : null}\n          <KeyValueRow label=\"Path\">{e.path}</KeyValueRow>\n          {e.query ? <KeyValueRow label=\"Query\">{e.query}</KeyValueRow> : null}\n          {e.fragment ? <KeyValueRow label=\"Fragment\">{e.fragment}</KeyValueRow> : null}\n        </KeyValueRows>\n      );\n    }\n\n    // Response status - show version and status separately\n    if (e.type === \"receive_url\") {\n      return (\n        <KeyValueRows>\n          <KeyValueRow label=\"HTTP Version\">{e.version}</KeyValueRow>\n          <KeyValueRow label=\"Status\">\n            <HttpStatusTagRaw status={e.status} />\n          </KeyValueRow>\n        </KeyValueRows>\n      );\n    }\n\n    // Redirect - show status, URL, and behavior\n    if (e.type === \"redirect\") {\n      const droppedHeaders = e.dropped_headers ?? [];\n      return (\n        <KeyValueRows>\n          <KeyValueRow label=\"Status\">\n            <HttpStatusTagRaw status={e.status} />\n          </KeyValueRow>\n          <KeyValueRow label=\"Location\">{e.url}</KeyValueRow>\n          <KeyValueRow label=\"Behavior\">\n            {e.behavior === \"drop_body\" ? \"Drop body, change to GET\" : \"Preserve method and body\"}\n          </KeyValueRow>\n          <KeyValueRow label=\"Body Dropped\">{e.dropped_body ? \"Yes\" : \"No\"}</KeyValueRow>\n          <KeyValueRow label=\"Headers Dropped\">\n            {droppedHeaders.length > 0 ? droppedHeaders.join(\", \") : \"--\"}\n          </KeyValueRow>\n        </KeyValueRows>\n      );\n    }\n\n    // Settings - show as key/value\n    if (e.type === \"setting\") {\n      return (\n        <KeyValueRows>\n          <KeyValueRow label=\"Setting\">{e.name}</KeyValueRow>\n          <KeyValueRow label=\"Value\">{e.value}</KeyValueRow>\n        </KeyValueRows>\n      );\n    }\n\n    // Chunks - show formatted bytes\n    if (e.type === \"chunk_sent\" || e.type === \"chunk_received\") {\n      return <div className=\"font-mono text-editor\">{formatBytes(e.bytes)}</div>;\n    }\n\n    // DNS Resolution - show hostname, addresses, and timing\n    if (e.type === \"dns_resolved\") {\n      return (\n        <KeyValueRows>\n          <KeyValueRow label=\"Hostname\">{e.hostname}</KeyValueRow>\n          <KeyValueRow label=\"Addresses\">{e.addresses.join(\", \")}</KeyValueRow>\n          <KeyValueRow label=\"Duration\">\n            {e.overridden ? (\n              <span className=\"text-text-subtlest\">--</span>\n            ) : (\n              `${String(e.duration)}ms`\n            )}\n          </KeyValueRow>\n          {e.overridden ? <KeyValueRow label=\"Source\">Workspace Override</KeyValueRow> : null}\n        </KeyValueRows>\n      );\n    }\n\n    // Default - use summary\n    const { summary } = getEventDisplay(event.event);\n    return <div className=\"font-mono text-editor\">{summary}</div>;\n  };\n  return (\n    <div className=\"flex flex-col gap-2 h-full\">\n      <EventDetailHeader\n        title={title}\n        timestamp={event.createdAt}\n        actions={actions}\n        onClose={onClose}\n      />\n      {renderContent()}\n    </div>\n  );\n}\n\ntype EventTextParts = { prefix: \">\" | \"<\" | \"*\"; text: string };\n\n/** Get the prefix and text for an event */\nfunction getEventTextParts(event: HttpResponseEventData): EventTextParts {\n  switch (event.type) {\n    case \"send_url\":\n      return {\n        prefix: \">\",\n        text: `${event.method} ${event.path}${event.query ? `?${event.query}` : \"\"}${event.fragment ? `#${event.fragment}` : \"\"}`,\n      };\n    case \"receive_url\":\n      return { prefix: \"<\", text: `${event.version} ${event.status}` };\n    case \"header_up\":\n      return { prefix: \">\", text: `${event.name}: ${event.value}` };\n    case \"header_down\":\n      return { prefix: \"<\", text: `${event.name}: ${event.value}` };\n    case \"redirect\": {\n      const behavior = event.behavior === \"drop_body\" ? \"drop body\" : \"preserve\";\n      const droppedHeaders = event.dropped_headers ?? [];\n      const dropped = [\n        event.dropped_body ? \"body dropped\" : null,\n        droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(\", \")}` : null,\n      ]\n        .filter(Boolean)\n        .join(\", \");\n      return {\n        prefix: \"*\",\n        text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : \"\"})`,\n      };\n    }\n    case \"setting\":\n      return { prefix: \"*\", text: `Setting ${event.name}=${event.value}` };\n    case \"info\":\n      return { prefix: \"*\", text: event.message };\n    case \"chunk_sent\":\n      return { prefix: \"*\", text: `[${formatBytes(event.bytes)} sent]` };\n    case \"chunk_received\":\n      return { prefix: \"*\", text: `[${formatBytes(event.bytes)} received]` };\n    case \"dns_resolved\":\n      if (event.overridden) {\n        return {\n          prefix: \"*\",\n          text: `DNS override ${event.hostname} -> ${event.addresses.join(\", \")}`,\n        };\n      }\n      return {\n        prefix: \"*\",\n        text: `DNS resolved ${event.hostname} to ${event.addresses.join(\", \")} (${event.duration}ms)`,\n      };\n    default:\n      return { prefix: \"*\", text: \"[unknown event]\" };\n  }\n}\n\n/** Format event as plaintext, optionally with curl-style prefix (> outgoing, < incoming, * info) */\nfunction formatEventText(event: HttpResponseEventData, includePrefix: boolean): string {\n  const { prefix, text } = getEventTextParts(event);\n  return includePrefix ? `${prefix} ${text}` : text;\n}\n\ntype EventDisplay = {\n  icon: IconProps[\"icon\"];\n  color: IconProps[\"color\"];\n  label: string;\n  summary: ReactNode;\n};\n\nfunction getEventDisplay(event: HttpResponseEventData): EventDisplay {\n  switch (event.type) {\n    case \"setting\":\n      return {\n        icon: \"settings\",\n        color: \"secondary\",\n        label: \"Setting\",\n        summary: `${event.name} = ${event.value}`,\n      };\n    case \"info\":\n      return {\n        icon: \"info\",\n        color: \"secondary\",\n        label: \"Info\",\n        summary: event.message,\n      };\n    case \"redirect\": {\n      const droppedHeaders = event.dropped_headers ?? [];\n      const dropped = [\n        event.dropped_body ? \"drop body\" : null,\n        droppedHeaders.length > 0\n          ? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? \"header\" : \"headers\"}`\n          : null,\n      ]\n        .filter(Boolean)\n        .join(\", \");\n      return {\n        icon: \"arrow_big_right_dash\",\n        color: \"success\",\n        label: \"Redirect\",\n        summary: `Redirecting ${event.status} ${event.url}${dropped ? ` (${dropped})` : \"\"}`,\n      };\n    }\n    case \"send_url\":\n      return {\n        icon: \"arrow_big_up_dash\",\n        color: \"primary\",\n        label: \"Request\",\n        summary: `${event.method} ${event.path}${event.query ? `?${event.query}` : \"\"}${event.fragment ? `#${event.fragment}` : \"\"}`,\n      };\n    case \"receive_url\":\n      return {\n        icon: \"arrow_big_down_dash\",\n        color: \"info\",\n        label: \"Response\",\n        summary: `${event.version} ${event.status}`,\n      };\n    case \"header_up\":\n      return {\n        icon: \"arrow_big_up_dash\",\n        color: \"primary\",\n        label: \"Header\",\n        summary: `${event.name}: ${event.value}`,\n      };\n    case \"header_down\":\n      return {\n        icon: \"arrow_big_down_dash\",\n        color: \"info\",\n        label: \"Header\",\n        summary: `${event.name}: ${event.value}`,\n      };\n\n    case \"chunk_sent\":\n      return {\n        icon: \"info\",\n        color: \"secondary\",\n        label: \"Chunk\",\n        summary: `${formatBytes(event.bytes)} chunk sent`,\n      };\n    case \"chunk_received\":\n      return {\n        icon: \"info\",\n        color: \"secondary\",\n        label: \"Chunk\",\n        summary: `${formatBytes(event.bytes)} chunk received`,\n      };\n    case \"dns_resolved\":\n      return {\n        icon: \"globe\",\n        color: event.overridden ? \"success\" : \"secondary\",\n        label: event.overridden ? \"DNS Override\" : \"DNS\",\n        summary: event.overridden\n          ? `${event.hostname} → ${event.addresses.join(\", \")} (overridden)`\n          : `${event.hostname} → ${event.addresses.join(\", \")} (${event.duration}ms)`,\n      };\n    default:\n      return {\n        icon: \"info\",\n        color: \"secondary\",\n        label: \"Unknown\",\n        summary: \"Unknown event\",\n      };\n  }\n}\n"
  },
  {
    "path": "src-web/components/ImportCurlButton.tsx",
    "content": "import { clear, readText } from \"@tauri-apps/plugin-clipboard-manager\";\nimport * as m from \"motion/react-m\";\nimport { useEffect, useState } from \"react\";\nimport { useImportCurl } from \"../hooks/useImportCurl\";\nimport { useWindowFocus } from \"../hooks/useWindowFocus\";\nimport { Button } from \"./core/Button\";\nimport { Icon } from \"./core/Icon\";\n\nexport function ImportCurlButton() {\n  const focused = useWindowFocus();\n  const [clipboardText, setClipboardText] = useState(\"\");\n\n  const importCurl = useImportCurl();\n  const [isLoading, setIsLoading] = useState(false);\n\n  // oxlint-disable-next-line react-hooks/exhaustive-deps\n  useEffect(() => {\n    readText()\n      .then(setClipboardText)\n      .catch(() => {});\n  }, [focused]);\n\n  if (!clipboardText?.trim().startsWith(\"curl \")) {\n    return null;\n  }\n\n  return (\n    <m.div\n      initial={{ opacity: 0, scale: 0 }}\n      animate={{ opacity: 1, scale: 1 }}\n      transition={{ delay: 0.5 }}\n    >\n      <Button\n        size=\"2xs\"\n        variant=\"border\"\n        color=\"success\"\n        className=\"rounded-full\"\n        rightSlot={<Icon icon=\"import\" size=\"sm\" />}\n        isLoading={isLoading}\n        title=\"Import Curl command from clipboard\"\n        onClick={async () => {\n          setIsLoading(true);\n          try {\n            await importCurl.mutateAsync({ command: clipboardText });\n            await clear(); // Clear the clipboard so the button goes away\n            setClipboardText(\"\");\n          } catch (e) {\n            console.log(\"Failed to import curl\", e);\n          } finally {\n            setIsLoading(false);\n          }\n        }}\n      >\n        Import Curl\n      </Button>\n    </m.div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/ImportDataDialog.tsx",
    "content": "import { useState } from \"react\";\nimport { useLocalStorage } from \"react-use\";\nimport { Button } from \"./core/Button\";\nimport { VStack } from \"./core/Stacks\";\nimport { SelectFile } from \"./SelectFile\";\n\ninterface Props {\n  importData: (filePath: string) => Promise<void>;\n}\n\nexport function ImportDataDialog({ importData }: Props) {\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [filePath, setFilePath] = useLocalStorage<string | null>(\"importFilePath\", null);\n\n  return (\n    <VStack space={5} className=\"pb-4\">\n      <VStack space={1}>\n        <ul className=\"list-disc pl-5\">\n          <li>OpenAPI 3.0, 3.1</li>\n          <li>Postman Collection v2, v2.1</li>\n          <li>Insomnia v4+</li>\n          <li>Swagger 2.0</li>\n          <li>\n            Curl commands <em className=\"text-text-subtle\">(or paste into URL)</em>\n          </li>\n        </ul>\n      </VStack>\n      <VStack space={2}>\n        <SelectFile\n          filePath={filePath ?? null}\n          onChange={({ filePath }) => setFilePath(filePath)}\n        />\n        {filePath && (\n          <Button\n            color=\"primary\"\n            disabled={!filePath || isLoading}\n            isLoading={isLoading}\n            size=\"sm\"\n            onClick={async () => {\n              setIsLoading(true);\n              try {\n                await importData(filePath);\n              } finally {\n                setIsLoading(false);\n              }\n            }}\n          >\n            {isLoading ? \"Importing\" : \"Import\"}\n          </Button>\n        )}\n      </VStack>\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/IsDev.tsx",
    "content": "import type { ReactNode } from \"react\";\nimport { appInfo } from \"../lib/appInfo\";\n\ninterface Props {\n  children: ReactNode;\n}\n\nexport function IsDev({ children }: Props) {\n  if (!appInfo.isDev) {\n    return null;\n  }\n\n  return <>{children}</>;\n}\n"
  },
  {
    "path": "src-web/components/JsonBodyEditor.tsx",
    "content": "import { linter } from \"@codemirror/lint\";\nimport type { HttpRequest } from \"@yaakapp-internal/models\";\nimport { patchModel } from \"@yaakapp-internal/models\";\nimport { useCallback, useMemo } from \"react\";\nimport { fireAndForget } from \"../lib/fireAndForget\";\nimport { useKeyValue } from \"../hooks/useKeyValue\";\nimport { textLikelyContainsJsonComments } from \"../lib/jsonComments\";\nimport { Banner } from \"./core/Banner\";\nimport type { DropdownItem } from \"./core/Dropdown\";\nimport { Dropdown } from \"./core/Dropdown\";\nimport type { EditorProps } from \"./core/Editor/Editor\";\nimport { jsonParseLinter } from \"./core/Editor/json-lint\";\nimport { Editor } from \"./core/Editor/LazyEditor\";\nimport { Icon } from \"./core/Icon\";\nimport { IconButton } from \"./core/IconButton\";\nimport { IconTooltip } from \"./core/IconTooltip\";\n\ninterface Props {\n  forceUpdateKey: string;\n  heightMode: EditorProps[\"heightMode\"];\n  request: HttpRequest;\n}\n\nexport function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {\n  const handleChange = useCallback(\n    (text: string) => patchModel(request, { body: { ...request.body, text } }),\n    [request],\n  );\n\n  const autoFix = request.body?.sendJsonComments !== true;\n\n  const lintExtension = useMemo(\n    () =>\n      linter(\n        jsonParseLinter(\n          autoFix\n            ? { allowComments: true, allowTrailingCommas: true }\n            : { allowComments: false, allowTrailingCommas: false },\n        ),\n      ),\n    [autoFix],\n  );\n\n  const hasComments = useMemo(\n    () => textLikelyContainsJsonComments(request.body?.text ?? \"\"),\n    [request.body?.text],\n  );\n\n  const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({\n    namespace: \"no_sync\",\n    key: [\"json-fix-3\", request.workspaceId],\n    fallback: false,\n  });\n\n  const handleToggleAutoFix = useCallback(() => {\n    const newBody = { ...request.body };\n    if (autoFix) {\n      newBody.sendJsonComments = true;\n    } else {\n      delete newBody.sendJsonComments;\n    }\n    fireAndForget(patchModel(request, { body: newBody }));\n  }, [request, autoFix]);\n\n  const handleDropdownOpen = useCallback(() => {\n    if (!bannerDismissed) {\n      fireAndForget(setBannerDismissed(true));\n    }\n  }, [bannerDismissed, setBannerDismissed]);\n\n  const showBanner = hasComments && autoFix && !bannerDismissed;\n\n  const stripMessage = \"Automatically strip comments and trailing commas before sending\";\n  const actions = useMemo<EditorProps[\"actions\"]>(\n    () => [\n      showBanner && (\n        <Banner color=\"notice\" className=\"!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs\">\n          <p className=\"inline-flex items-center gap-1 min-w-0\">\n            <span className=\"truncate\">Auto-fix enabled</span>\n            <Icon icon=\"arrow_right\" size=\"sm\" className=\"opacity-disabled\" />\n          </p>\n        </Banner>\n      ),\n      <div key=\"settings\" className=\"!opacity-100 !shadow\">\n        <Dropdown\n          onOpen={handleDropdownOpen}\n          items={\n            [\n              {\n                label: \"Automatically Fix JSON\",\n                keepOpenOnSelect: true,\n                onSelect: handleToggleAutoFix,\n                rightSlot: <IconTooltip content={stripMessage} />,\n                leftSlot: (\n                  <Icon icon={autoFix ? \"check_square_checked\" : \"check_square_unchecked\"} />\n                ),\n              },\n            ] satisfies DropdownItem[]\n          }\n        >\n          <IconButton size=\"sm\" variant=\"border\" icon=\"settings\" title=\"JSON Settings\" />\n        </Dropdown>\n      </div>,\n    ],\n    [handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner],\n  );\n\n  return (\n    <Editor\n      forceUpdateKey={forceUpdateKey}\n      autocompleteFunctions\n      autocompleteVariables\n      placeholder=\"...\"\n      heightMode={heightMode}\n      defaultValue={`${request.body?.text ?? \"\"}`}\n      language=\"json\"\n      onChange={handleChange}\n      stateKey={`json.${request.id}`}\n      actions={actions}\n      lintExtension={lintExtension}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/KeyboardShortcutsDialog.tsx",
    "content": "import { hotkeyActions } from \"../hooks/useHotKey\";\nimport { HotkeyList } from \"./core/HotkeyList\";\n\nexport function KeyboardShortcutsDialog() {\n  return (\n    <div className=\"grid h-full\">\n      <HotkeyList hotkeys={hotkeyActions} className=\"pb-6\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/LicenseBadge.tsx",
    "content": "import { openUrl } from \"@tauri-apps/plugin-opener\";\nimport type { LicenseCheckStatus } from \"@yaakapp-internal/license\";\nimport { useLicense } from \"@yaakapp-internal/license\";\nimport { settingsAtom } from \"@yaakapp-internal/models\";\nimport { differenceInCalendarDays } from \"date-fns\";\nimport { formatDate } from \"date-fns/format\";\nimport { useAtomValue } from \"jotai\";\nimport type { ReactNode } from \"react\";\nimport { openSettings } from \"../commands/openSettings\";\nimport { atomWithKVStorage } from \"../lib/atoms/atomWithKVStorage\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { CargoFeature } from \"./CargoFeature\";\nimport type { ButtonProps } from \"./core/Button\";\nimport { Dropdown, type DropdownItem } from \"./core/Dropdown\";\nimport { Icon } from \"./core/Icon\";\nimport { PillButton } from \"./core/PillButton\";\n\nconst dismissedAtom = atomWithKVStorage<string | null>(\"dismissed_license_expired\", null);\n\nfunction getDetail(\n  data: LicenseCheckStatus,\n  dismissedExpired: string | null,\n): { label: ReactNode; color: ButtonProps[\"color\"]; options?: DropdownItem[] } | null | undefined {\n  const dismissedAt = dismissedExpired ? new Date(dismissedExpired).getTime() : null;\n\n  switch (data.status) {\n    case \"active\":\n      return null;\n    case \"personal_use\":\n      return { label: \"Personal Use\", color: \"notice\" };\n    case \"trialing\":\n      return { label: \"Commercial Trial\", color: \"secondary\" };\n    case \"error\":\n      return { label: \"Error\", color: \"danger\" };\n    case \"inactive\":\n      return { label: \"Personal Use\", color: \"notice\" };\n    case \"past_due\":\n      return { label: \"Past Due\", color: \"danger\" };\n    case \"expired\":\n      // Don't show the expired message if it's been less than 14 days since the last dismissal\n      if (dismissedAt && differenceInCalendarDays(new Date(), dismissedAt) < 14) {\n        return null;\n      }\n\n      return {\n        color: \"notice\",\n        label: data.data.changes > 0 ? \"Updates Paused\" : \"License Expired\",\n        options: [\n          {\n            label: `${data.data.changes} New Updates`,\n            color: \"success\",\n            leftSlot: <Icon icon=\"gift\" />,\n            rightSlot: <Icon icon=\"external_link\" size=\"sm\" className=\"opacity-disabled\" />,\n            hidden: data.data.changes === 0 || data.data.changesUrl == null,\n            onSelect: () => openUrl(data.data.changesUrl ?? \"\"),\n          },\n          {\n            type: \"separator\",\n            label: `License expired ${formatDate(data.data.periodEnd, \"MMM dd, yyyy\")}`,\n          },\n          {\n            label: <div className=\"min-w-[12rem]\">Renew License</div>,\n            leftSlot: <Icon icon=\"refresh\" />,\n            rightSlot: <Icon icon=\"external_link\" size=\"sm\" className=\"opacity-disabled\" />,\n            hidden: data.data.changesUrl == null,\n            onSelect: () => openUrl(data.data.billingUrl),\n          },\n          {\n            label: \"Enter License Key\",\n            leftSlot: <Icon icon=\"key_round\" />,\n            hidden: data.data.changesUrl == null,\n            onSelect: openLicenseDialog,\n          },\n          { type: \"separator\" },\n          {\n            label: <span className=\"text-text-subtle\">Remind me Later</span>,\n            leftSlot: <Icon icon=\"alarm_clock\" className=\"text-text-subtle\" />,\n            onSelect: () => jotaiStore.set(dismissedAtom, new Date().toISOString()),\n          },\n        ],\n      };\n  }\n}\n\nexport function LicenseBadge() {\n  return (\n    <CargoFeature feature=\"license\">\n      <LicenseBadgeCmp />\n    </CargoFeature>\n  );\n}\n\nfunction LicenseBadgeCmp() {\n  const { check } = useLicense();\n  const settings = useAtomValue(settingsAtom);\n  const dismissed = useAtomValue(dismissedAtom);\n\n  // Dismissed license badge\n  if (settings.hideLicenseBadge) {\n    return null;\n  }\n\n  if (check.error) {\n    // Failed to check for license. Probably a network or server error, so just don't show anything.\n    return null;\n  }\n\n  // Hasn't loaded yet\n  if (check.data == null) {\n    return null;\n  }\n\n  const detail = getDetail(check.data, dismissed);\n  if (detail == null) {\n    return null;\n  }\n\n  if (detail.options && detail.options.length > 0) {\n    return (\n      <Dropdown items={detail.options}>\n        <PillButton color={detail.color}>\n          <div className=\"flex items-center gap-0.5\">\n            {detail.label} <Icon icon=\"chevron_down\" className=\"opacity-60\" />\n          </div>\n        </PillButton>\n      </Dropdown>\n    );\n  }\n\n  return (\n    <PillButton color={detail.color} onClick={openLicenseDialog}>\n      {detail.label}\n    </PillButton>\n  );\n}\n\nfunction openLicenseDialog() {\n  openSettings.mutate(\"license\");\n}\n"
  },
  {
    "path": "src-web/components/LocalImage.tsx",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport { convertFileSrc } from \"@tauri-apps/api/core\";\nimport { resolveResource } from \"@tauri-apps/api/path\";\nimport classNames from \"classnames\";\n\ninterface Props {\n  src: string;\n  className?: string;\n}\n\nexport function LocalImage({ src: srcPath, className }: Props) {\n  const src = useQuery({\n    queryKey: [\"local-image\", srcPath],\n    queryFn: async () => {\n      const p = await resolveResource(srcPath);\n      return convertFileSrc(p);\n    },\n  });\n\n  return (\n    <img\n      src={src.data}\n      alt=\"Response preview\"\n      className={classNames(\n        className,\n        \"transition-opacity\",\n        src.data == null ? \"opacity-0\" : \"opacity-100\",\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/Markdown.tsx",
    "content": "import type { CSSProperties } from \"react\";\nimport ReactMarkdown, { type Components } from \"react-markdown\";\nimport { PrismLight as SyntaxHighlighter } from \"react-syntax-highlighter\";\nimport remarkGfm from \"remark-gfm\";\nimport { ErrorBoundary } from \"./ErrorBoundary\";\nimport { Prose } from \"./Prose\";\n\ninterface Props {\n  children: string | null;\n  className?: string;\n}\n\nexport function Markdown({ children, className }: Props) {\n  if (children == null) return null;\n\n  return (\n    <Prose className={className}>\n      <ErrorBoundary name=\"Markdown\">\n        <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>\n          {children}\n        </ReactMarkdown>\n      </ErrorBoundary>\n    </Prose>\n  );\n}\n\nconst prismTheme = {\n  'pre[class*=\"language-\"]': {\n    // Needs to be here, so the lib doesn't add its own\n  },\n\n  // Syntax tokens\n  comment: { color: \"var(--textSubtle)\" },\n  prolog: { color: \"var(--textSubtle)\" },\n  doctype: { color: \"var(--textSubtle)\" },\n  cdata: { color: \"var(--textSubtle)\" },\n\n  punctuation: { color: \"var(--textSubtle)\" },\n\n  property: { color: \"var(--primary)\" },\n  \"attr-name\": { color: \"var(--primary)\" },\n\n  string: { color: \"var(--notice)\" },\n  char: { color: \"var(--notice)\" },\n\n  number: { color: \"var(--info)\" },\n  constant: { color: \"var(--info)\" },\n  symbol: { color: \"var(--info)\" },\n\n  boolean: { color: \"var(--warning)\" },\n  \"attr-value\": { color: \"var(--warning)\" },\n\n  variable: { color: \"var(--success)\" },\n\n  tag: { color: \"var(--info)\" },\n  operator: { color: \"var(--danger)\" },\n  keyword: { color: \"var(--danger)\" },\n  function: { color: \"var(--success)\" },\n  \"class-name\": { color: \"var(--primary)\" },\n  builtin: { color: \"var(--danger)\" },\n  selector: { color: \"var(--danger)\" },\n  inserted: { color: \"var(--success)\" },\n  deleted: { color: \"var(--danger)\" },\n  regex: { color: \"var(--warning)\" },\n\n  important: { color: \"var(--danger)\", fontWeight: \"bold\" },\n  italic: { fontStyle: \"italic\" },\n  bold: { fontWeight: \"bold\" },\n  entity: { cursor: \"help\" },\n};\n\nconst lineStyle: CSSProperties = {\n  paddingRight: \"1.5em\",\n  paddingLeft: \"0\",\n  opacity: 0.5,\n};\n\nconst markdownComponents: Partial<Components> = {\n  // Ensure links open in external browser by adding target=\"_blank\"\n  a: ({ href, children, ...rest }) => {\n    if (href && !href.match(/https?:\\/\\//)) {\n      href = `http://${href}`;\n    }\n    return (\n      <a target=\"_blank\" rel=\"noreferrer noopener\" href={href} {...rest}>\n        {children}\n      </a>\n    );\n  },\n  code(props) {\n    const { children, className, ref, ...extraProps } = props;\n    extraProps.node = undefined;\n\n    const match = /language-(\\w+)/.exec(className || \"\");\n    return match ? (\n      <SyntaxHighlighter\n        {...extraProps}\n        CodeTag=\"code\"\n        showLineNumbers\n        PreTag=\"div\"\n        lineNumberStyle={lineStyle}\n        language={match[1]}\n        style={prismTheme}\n      >\n        {String(children as string).replace(/\\n$/, \"\")}\n      </SyntaxHighlighter>\n    ) : (\n      <code {...extraProps} ref={ref} className={className}>\n        {children}\n      </code>\n    );\n  },\n};\n"
  },
  {
    "path": "src-web/components/MarkdownEditor.tsx",
    "content": "import classNames from \"classnames\";\nimport { useRef, useState } from \"react\";\nimport type { EditorProps } from \"./core/Editor/Editor\";\nimport { Editor } from \"./core/Editor/LazyEditor\";\nimport { SegmentedControl } from \"./core/SegmentedControl\";\nimport { Markdown } from \"./Markdown\";\n\ntype ViewMode = \"edit\" | \"preview\";\n\ninterface Props extends Pick<EditorProps, \"heightMode\" | \"stateKey\" | \"forceUpdateKey\"> {\n  placeholder: string;\n  className?: string;\n  editorClassName?: string;\n  defaultValue: string;\n  onChange: (value: string) => void;\n  name: string;\n}\n\nexport function MarkdownEditor({\n  className,\n  editorClassName,\n  defaultValue,\n  onChange,\n  name,\n  forceUpdateKey,\n  ...editorProps\n}: Props) {\n  const [viewMode, setViewMode] = useState<ViewMode>(defaultValue ? \"preview\" : \"edit\");\n\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const editor = (\n    <Editor\n      hideGutter\n      wrapLines\n      className={classNames(editorClassName, \"[&_.cm-line]:!max-w-lg max-h-full\")}\n      language=\"markdown\"\n      defaultValue={defaultValue}\n      onChange={onChange}\n      forceUpdateKey={forceUpdateKey}\n      {...editorProps}\n    />\n  );\n\n  const preview =\n    defaultValue.length === 0 ? (\n      <p className=\"text-text-subtlest\">No description</p>\n    ) : (\n      <div className=\"pr-1.5 overflow-y-auto max-h-full [&_*]:cursor-auto [&_*]:select-auto\">\n        <Markdown className=\"max-w-lg select-auto cursor-auto\">{defaultValue}</Markdown>\n      </div>\n    );\n\n  const contents = viewMode === \"preview\" ? preview : editor;\n\n  return (\n    <div\n      ref={containerRef}\n      className={classNames(\n        \"group/markdown\",\n        \"relative w-full h-full pt-1.5 rounded-md gap-x-1.5\",\n        \"min-w-0\", // Not sure why this is needed\n        className,\n      )}\n    >\n      <div className=\"h-full w-full\">{contents}</div>\n      <div className=\"absolute top-0 right-0 pt-1.5 pr-1.5\">\n        <SegmentedControl\n          name={name}\n          label=\"View mode\"\n          hideLabel\n          onChange={setViewMode}\n          value={viewMode}\n          className=\"opacity-0 group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100\"\n          options={[\n            { icon: \"eye\", label: \"Preview mode\", value: \"preview\" },\n            { icon: \"pencil\", label: \"Edit mode\", value: \"edit\" },\n          ]}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/MoveToWorkspaceDialog.tsx",
    "content": "import type { GrpcRequest, HttpRequest, WebsocketRequest } from \"@yaakapp-internal/models\";\nimport { patchModel, workspacesAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useState } from \"react\";\nimport { pluralizeCount } from \"../lib/pluralize\";\nimport { resolvedModelName } from \"../lib/resolvedModelName\";\nimport { router } from \"../lib/router\";\nimport { showToast } from \"../lib/toast\";\nimport { Button } from \"./core/Button\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport { Select } from \"./core/Select\";\nimport { VStack } from \"./core/Stacks\";\n\ninterface Props {\n  activeWorkspaceId: string;\n  requests: (HttpRequest | GrpcRequest | WebsocketRequest)[];\n  onDone: () => void;\n}\n\nexport function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: Props) {\n  const workspaces = useAtomValue(workspacesAtom);\n  const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>(activeWorkspaceId);\n\n  const targetWorkspace = workspaces.find((w) => w.id === selectedWorkspaceId);\n  const isSameWorkspace = selectedWorkspaceId === activeWorkspaceId;\n\n  return (\n    <VStack space={4} className=\"mb-4\">\n      <Select\n        label=\"Target Workspace\"\n        name=\"workspace\"\n        value={selectedWorkspaceId}\n        onChange={setSelectedWorkspaceId}\n        options={workspaces.map((w) => ({\n          label: w.id === activeWorkspaceId ? `${w.name} (current)` : w.name,\n          value: w.id,\n        }))}\n      />\n      <Button\n        color=\"primary\"\n        disabled={isSameWorkspace}\n        onClick={async () => {\n          const patch = {\n            workspaceId: selectedWorkspaceId,\n            folderId: null,\n          };\n\n          await Promise.all(requests.map((r) => patchModel(r, patch)));\n\n          // Hide after a moment, to give time for requests to disappear\n          setTimeout(onDone, 100);\n          showToast({\n            id: \"workspace-moved\",\n            message:\n              requests.length === 1 && requests[0] != null ? (\n                <>\n                  <InlineCode>{resolvedModelName(requests[0])}</InlineCode> moved to{\" \"}\n                  <InlineCode>{targetWorkspace?.name ?? \"unknown\"}</InlineCode>\n                </>\n              ) : (\n                <>\n                  {pluralizeCount(\"request\", requests.length)} moved to{\" \"}\n                  <InlineCode>{targetWorkspace?.name ?? \"unknown\"}</InlineCode>\n                </>\n              ),\n            action: ({ hide }) => (\n              <Button\n                size=\"xs\"\n                color=\"secondary\"\n                className=\"mr-auto min-w-[5rem]\"\n                onClick={async () => {\n                  await router.navigate({\n                    to: \"/workspaces/$workspaceId\",\n                    params: { workspaceId: selectedWorkspaceId },\n                  });\n                  hide();\n                }}\n              >\n                Switch to Workspace\n              </Button>\n            ),\n          });\n        }}\n      >\n        {requests.length === 1 ? \"Move\" : `Move ${pluralizeCount(\"Request\", requests.length)}`}\n      </Button>\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/Overlay.tsx",
    "content": "import classNames from \"classnames\";\nimport { FocusTrap } from \"focus-trap-react\";\nimport * as m from \"motion/react-m\";\nimport type { ReactNode } from \"react\";\nimport { useRef } from \"react\";\nimport { Portal } from \"./Portal\";\n\ninterface Props {\n  children: ReactNode;\n  portalName: string;\n  open: boolean;\n  onClose?: () => void;\n  zIndex?: keyof typeof zIndexes;\n  variant?: \"default\" | \"transparent\";\n  noBackdrop?: boolean;\n}\n\nconst zIndexes: Record<number, string> = {\n  10: \"z-10\",\n  20: \"z-20\",\n  30: \"z-30\",\n  40: \"z-40\",\n  50: \"z-50\",\n};\n\nexport function Overlay({\n  variant = \"default\",\n  zIndex = 30,\n  open,\n  onClose,\n  portalName,\n  noBackdrop,\n  children,\n}: Props) {\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  if (noBackdrop) {\n    return (\n      <Portal name={portalName}>\n        {open && (\n          <FocusTrap focusTrapOptions={{ clickOutsideDeactivates: true }}>\n            {/* NOTE: <div> wrapper is required for some reason, or FocusTrap complains */}\n            <div>{children}</div>\n          </FocusTrap>\n        )}\n      </Portal>\n    );\n  }\n\n  return (\n    <Portal name={portalName}>\n      {open && (\n        <FocusTrap\n          focusTrapOptions={{\n            // Allow outside click so we can click things like toasts\n            allowOutsideClick: true,\n            delayInitialFocus: true,\n            checkCanFocusTrap: async () => {\n              // Not sure why delayInitialFocus: true doesn't help, but having this no-op promise\n              // seems to be required to make things work.\n            },\n          }}\n        >\n          <m.div\n            ref={containerRef}\n            className={classNames(\"fixed inset-0\", zIndexes[zIndex])}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n          >\n            <div\n              aria-hidden\n              onClick={onClose}\n              className={classNames(\n                \"absolute inset-0\",\n                variant === \"default\" && \"bg-backdrop backdrop-blur-sm\",\n              )}\n            />\n\n            {/* Show the draggable region at the top */}\n            {/* TODO: Figure out tauri drag region and also make clickable still */}\n            {variant === \"default\" && (\n              <div data-tauri-drag-region className=\"absolute top-0 left-0 h-md right-0\" />\n            )}\n            {children}\n          </m.div>\n        </FocusTrap>\n      )}\n    </Portal>\n  );\n}\n"
  },
  {
    "path": "src-web/components/Portal.tsx",
    "content": "import type { ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { usePortal } from \"../hooks/usePortal\";\n\ninterface Props {\n  children: ReactNode;\n  name: string;\n}\n\nexport function Portal({ children, name }: Props) {\n  const portal = usePortal(name);\n  return createPortal(children, portal);\n}\n"
  },
  {
    "path": "src-web/components/Prose.css",
    "content": ".prose {\n  @apply text-text;\n\n  & > :first-child {\n    @apply mt-0;\n  }\n\n  & > :last-child {\n    @apply mb-0;\n  }\n\n  img,\n  video,\n  p,\n  ul,\n  ol,\n  table,\n  blockquote,\n  hr,\n  h1,\n  h2,\n  h3,\n  h4,\n  h5,\n  h6 {\n    @apply my-5;\n  }\n\n  h1,\n  h2,\n  h3,\n  h4,\n  h5,\n  h6 {\n    @apply mt-10 leading-tight text-balance;\n  }\n\n  p {\n    @apply text-pretty;\n  }\n\n  h1 {\n    @apply text-4xl font-bold;\n  }\n\n  h2 {\n    @apply text-2xl font-bold;\n  }\n\n  h3 {\n    @apply text-xl font-bold;\n  }\n\n  em {\n    @apply italic;\n  }\n\n  strong {\n    @apply font-bold;\n  }\n\n  ul {\n    @apply list-disc;\n\n    ul,\n    ol {\n      @apply my-0;\n    }\n  }\n\n  ol {\n    @apply list-decimal;\n\n    ol,\n    ul {\n      @apply my-0;\n    }\n  }\n\n  ol,\n  ul {\n    @apply pl-6;\n\n    li p {\n      @apply inline-block my-0;\n    }\n\n    li {\n      @apply pl-2;\n    }\n\n    li::marker {\n      @apply text-success;\n    }\n  }\n\n  a {\n    @apply text-notice hover:underline;\n\n    * {\n      @apply text-notice !important;\n    }\n  }\n\n  img,\n  video {\n    @apply max-h-[65vh];\n    @apply w-auto mx-auto rounded-md;\n  }\n\n  table code,\n  p code,\n  ol code,\n  ul code {\n    @apply text-xs bg-surface-active text-info font-normal whitespace-nowrap;\n    @apply px-1.5 py-0.5 rounded not-italic;\n    @apply select-text;\n  }\n\n  pre {\n    @apply bg-surface-highlight text-text !important;\n    @apply px-4 py-3 rounded-md;\n    @apply overflow-auto whitespace-pre;\n    @apply text-editor font-mono;\n\n    code {\n      @apply font-normal;\n    }\n  }\n\n  .banner {\n    @apply border border-dashed;\n    @apply border-border bg-surface-highlight text-text px-4 py-3 rounded text-base;\n\n    &::before {\n      @apply block font-bold mb-1;\n      @apply text-text-subtlest;\n\n      content: \"Note\";\n    }\n\n    &.x-theme-banner--secondary::before {\n      content: \"Info\";\n    }\n\n    &.x-theme-banner--success::before {\n      content: \"Tip\";\n    }\n\n    &.x-theme-banner--notice::before {\n      content: \"Important\";\n    }\n\n    &.x-theme-banner--warning::before {\n      content: \"Warning\";\n    }\n\n    &.x-theme-banner--danger::before {\n      content: \"Caution\";\n    }\n  }\n\n  blockquote {\n    @apply italic py-3 pl-5 pr-3 border-l-8 border-surface-active text-lg text-text bg-surface-highlight rounded shadow-lg;\n\n    p {\n      @apply m-0;\n    }\n  }\n\n  h2[id] > a .icon.icon-link {\n    @apply hidden w-4 h-4 bg-success ml-2;\n    mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24' stroke='currentColor' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'%3E%3C/path%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'%3E%3C/path%3E%3C/svg%3E\");\n    mask-size: contain;\n    mask-repeat: no-repeat;\n\n    &:hover {\n      @apply bg-notice;\n    }\n  }\n\n  h2[id]:hover {\n    .icon.icon-link {\n      @apply inline-block;\n    }\n  }\n\n  hr {\n    @apply border-secondary border-dashed md:mx-[25%] my-10;\n  }\n\n  figure {\n    img {\n      @apply mb-0;\n    }\n\n    figcaption {\n      @apply relative pl-9 text-success text-sm pt-1;\n\n      p {\n        @apply m-0;\n      }\n    }\n\n    figcaption::before {\n      @apply border-info absolute left-2 top-0 h-3.5 w-6 rounded-bl border-l border-b border-dotted;\n      content: \"\";\n    }\n  }\n}\n"
  },
  {
    "path": "src-web/components/Prose.tsx",
    "content": "import classNames from \"classnames\";\nimport type { ReactNode } from \"react\";\nimport \"./Prose.css\";\n\ninterface Props {\n  children: ReactNode;\n  className?: string;\n}\n\nexport function Prose({ className, ...props }: Props) {\n  return <div className={classNames(\"prose\", className)} {...props} />;\n}\n"
  },
  {
    "path": "src-web/components/RecentGrpcConnectionsDropdown.tsx",
    "content": "import type { GrpcConnection } from \"@yaakapp-internal/models\";\nimport { deleteModel } from \"@yaakapp-internal/models\";\nimport { formatDistanceToNowStrict } from \"date-fns\";\nimport { useDeleteGrpcConnections } from \"../hooks/useDeleteGrpcConnections\";\nimport { pluralizeCount } from \"../lib/pluralize\";\nimport { Dropdown } from \"./core/Dropdown\";\nimport { Icon } from \"./core/Icon\";\nimport { IconButton } from \"./core/IconButton\";\nimport { HStack } from \"./core/Stacks\";\n\ninterface Props {\n  connections: GrpcConnection[];\n  activeConnection: GrpcConnection;\n  onPinnedConnectionId: (id: string) => void;\n}\n\nexport function RecentGrpcConnectionsDropdown({\n  activeConnection,\n  connections,\n  onPinnedConnectionId,\n}: Props) {\n  const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);\n  const latestConnectionId = connections[0]?.id ?? \"n/a\";\n\n  return (\n    <Dropdown\n      items={[\n        {\n          label: \"Clear Connection\",\n          onSelect: () => deleteModel(activeConnection),\n          disabled: connections.length === 0,\n        },\n        {\n          label: `Clear ${pluralizeCount(\"Connection\", connections.length)}`,\n          onSelect: deleteAllConnections.mutate,\n          hidden: connections.length <= 1,\n          disabled: connections.length === 0,\n        },\n        { type: \"separator\", label: \"History\" },\n        ...connections.map((c) => ({\n          label: (\n            <HStack space={2}>\n              {formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{\" \"}\n              <span className=\"font-mono text-sm\">{c.elapsed}ms</span>\n            </HStack>\n          ),\n          leftSlot: activeConnection?.id === c.id ? <Icon icon=\"check\" /> : <Icon icon=\"empty\" />,\n          onSelect: () => onPinnedConnectionId(c.id),\n        })),\n      ]}\n    >\n      <IconButton\n        title=\"Show connection history\"\n        icon={activeConnection?.id === latestConnectionId ? \"history\" : \"pin\"}\n        className=\"m-0.5 text-text-subtle\"\n        size=\"sm\"\n        iconSize=\"md\"\n      />\n    </Dropdown>\n  );\n}\n"
  },
  {
    "path": "src-web/components/RecentHttpResponsesDropdown.tsx",
    "content": "import type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { deleteModel } from \"@yaakapp-internal/models\";\nimport { useCopyHttpResponse } from \"../hooks/useCopyHttpResponse\";\nimport { useDeleteHttpResponses } from \"../hooks/useDeleteHttpResponses\";\nimport { useSaveResponse } from \"../hooks/useSaveResponse\";\nimport { pluralize } from \"../lib/pluralize\";\nimport { Dropdown } from \"./core/Dropdown\";\nimport { HttpStatusTag } from \"./core/HttpStatusTag\";\nimport { Icon } from \"./core/Icon\";\nimport { IconButton } from \"./core/IconButton\";\nimport { HStack } from \"./core/Stacks\";\n\ninterface Props {\n  responses: HttpResponse[];\n  activeResponse: HttpResponse;\n  onPinnedResponseId: (id: string) => void;\n  className?: string;\n}\n\nexport const RecentHttpResponsesDropdown = function ResponsePane({\n  activeResponse,\n  responses,\n  onPinnedResponseId,\n}: Props) {\n  const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);\n  const latestResponseId = responses[0]?.id ?? \"n/a\";\n  const saveResponse = useSaveResponse(activeResponse);\n  const copyResponse = useCopyHttpResponse(activeResponse);\n\n  return (\n    <Dropdown\n      items={[\n        {\n          label: \"Save to File\",\n          onSelect: saveResponse.mutate,\n          leftSlot: <Icon icon=\"save\" />,\n          hidden: responses.length === 0 || !!activeResponse.error,\n          disabled: activeResponse.state !== \"closed\" && activeResponse.status >= 100,\n        },\n        {\n          label: \"Copy Body\",\n          onSelect: copyResponse.mutate,\n          leftSlot: <Icon icon=\"copy\" />,\n          hidden: responses.length === 0 || !!activeResponse.error,\n          disabled: activeResponse.state !== \"closed\" && activeResponse.status >= 100,\n        },\n        {\n          label: \"Delete\",\n          leftSlot: <Icon icon=\"trash\" />,\n          onSelect: () => deleteModel(activeResponse),\n        },\n        {\n          label: \"Unpin Response\",\n          onSelect: () => onPinnedResponseId(activeResponse.id),\n          leftSlot: <Icon icon=\"unpin\" />,\n          hidden: latestResponseId === activeResponse.id,\n          disabled: responses.length === 0,\n        },\n        { type: \"separator\", label: \"History\" },\n        {\n          label: `Delete ${responses.length} ${pluralize(\"Response\", responses.length)}`,\n          onSelect: deleteAllResponses.mutate,\n          hidden: responses.length === 0,\n          disabled: responses.length === 0,\n        },\n        { type: \"separator\" },\n        ...responses.map((r: HttpResponse) => ({\n          label: (\n            <HStack space={2}>\n              <HttpStatusTag short className=\"text-xs\" response={r} />\n              <span className=\"text-text-subtle\">&rarr;</span>{\" \"}\n              <span className=\"font-mono text-sm\">{r.elapsed >= 0 ? `${r.elapsed}ms` : \"n/a\"}</span>\n            </HStack>\n          ),\n          leftSlot: activeResponse?.id === r.id ? <Icon icon=\"check\" /> : <Icon icon=\"empty\" />,\n          onSelect: () => onPinnedResponseId(r.id),\n        })),\n      ]}\n    >\n      <IconButton\n        title=\"Show response history\"\n        icon={activeResponse?.id === latestResponseId ? \"history\" : \"pin\"}\n        className=\"m-0.5 text-text-subtle\"\n        size=\"sm\"\n        iconSize=\"md\"\n      />\n    </Dropdown>\n  );\n};\n"
  },
  {
    "path": "src-web/components/RecentRequestsDropdown.tsx",
    "content": "import classNames from \"classnames\";\nimport { useMemo, useRef } from \"react\";\nimport { useActiveRequest } from \"../hooks/useActiveRequest\";\nimport { activeWorkspaceIdAtom } from \"../hooks/useActiveWorkspace\";\nimport { allRequestsAtom } from \"../hooks/useAllRequests\";\nimport { useHotKey } from \"../hooks/useHotKey\";\nimport { useKeyboardEvent } from \"../hooks/useKeyboardEvent\";\nimport { useRecentRequests } from \"../hooks/useRecentRequests\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { resolvedModelName } from \"../lib/resolvedModelName\";\nimport { router } from \"../lib/router\";\nimport { Button } from \"./core/Button\";\nimport type { DropdownItem, DropdownRef } from \"./core/Dropdown\";\nimport { Dropdown } from \"./core/Dropdown\";\nimport { HttpMethodTag } from \"./core/HttpMethodTag\";\n\ninterface Props {\n  className?: string;\n}\n\nexport function RecentRequestsDropdown({ className }: Props) {\n  const activeRequest = useActiveRequest();\n  const dropdownRef = useRef<DropdownRef>(null);\n  const [recentRequestIds] = useRecentRequests();\n\n  // Handle key-up\n  // TODO: Somehow make useHotKey have this functionality. Note: e.key does not work\n  //  on Linux, for example, when Control is mapped to CAPS. This will never fire.\n  useKeyboardEvent(\"keyup\", \"Control\", () => {\n    if (dropdownRef.current?.isOpen) {\n      dropdownRef.current?.select?.();\n    }\n  });\n\n  useHotKey(\"switcher.prev\", () => {\n    if (!dropdownRef.current?.isOpen) {\n      // Select the second because the first is the current request\n      dropdownRef.current?.open(1);\n    } else {\n      dropdownRef.current?.next?.();\n    }\n  });\n\n  useHotKey(\"switcher.next\", () => {\n    if (!dropdownRef.current?.isOpen) dropdownRef.current?.open();\n    dropdownRef.current?.prev?.();\n  });\n\n  const items = useMemo(() => {\n    const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n    if (activeWorkspaceId === null) return [];\n\n    const requests = jotaiStore.get(allRequestsAtom);\n    const recentRequestItems: DropdownItem[] = [];\n    for (const id of recentRequestIds) {\n      const request = requests.find((r) => r.id === id);\n      if (request === undefined) continue;\n\n      recentRequestItems.push({\n        label: resolvedModelName(request),\n        leftSlot: <HttpMethodTag short className=\"text-xs\" request={request} />,\n        onSelect: async () => {\n          await router.navigate({\n            to: \"/workspaces/$workspaceId\",\n            params: { workspaceId: activeWorkspaceId },\n            search: (prev) => ({ ...prev, request_id: request.id }),\n          });\n        },\n      });\n    }\n\n    // No recent requests to show\n    if (recentRequestItems.length === 0) {\n      return [\n        {\n          key: \"no-recent-requests\",\n          label: \"No recent requests\",\n          disabled: true,\n        },\n      ];\n    }\n\n    return recentRequestItems.slice(0, 20);\n  }, [recentRequestIds]);\n\n  return (\n    <Dropdown ref={dropdownRef} items={items}>\n      <Button\n        size=\"sm\"\n        hotkeyAction=\"switcher.toggle\"\n        className={classNames(\n          className,\n          \"truncate pointer-events-auto\",\n          activeRequest == null && \"text-text-subtlest italic\",\n        )}\n      >\n        {resolvedModelName(activeRequest)}\n      </Button>\n    </Dropdown>\n  );\n}\n"
  },
  {
    "path": "src-web/components/RecentWebsocketConnectionsDropdown.tsx",
    "content": "import type { WebsocketConnection } from \"@yaakapp-internal/models\";\nimport { deleteModel, getModel } from \"@yaakapp-internal/models\";\nimport { formatDistanceToNowStrict } from \"date-fns\";\nimport { deleteWebsocketConnections } from \"../commands/deleteWebsocketConnections\";\nimport { pluralizeCount } from \"../lib/pluralize\";\nimport { Dropdown } from \"./core/Dropdown\";\nimport { Icon } from \"./core/Icon\";\nimport { IconButton } from \"./core/IconButton\";\nimport { HStack } from \"./core/Stacks\";\n\ninterface Props {\n  connections: WebsocketConnection[];\n  activeConnection: WebsocketConnection;\n  onPinnedConnectionId: (id: string) => void;\n}\n\nexport function RecentWebsocketConnectionsDropdown({\n  activeConnection,\n  connections,\n  onPinnedConnectionId,\n}: Props) {\n  const latestConnectionId = connections[0]?.id ?? \"n/a\";\n\n  return (\n    <Dropdown\n      items={[\n        {\n          label: \"Clear Connection\",\n          onSelect: () => deleteModel(activeConnection),\n          disabled: connections.length === 0,\n        },\n        {\n          label: `Clear ${pluralizeCount(\"Connection\", connections.length)}`,\n          onSelect: () => {\n            const request = getModel(\"websocket_request\", activeConnection.requestId);\n            if (request != null) {\n              deleteWebsocketConnections.mutate(request);\n            }\n          },\n          hidden: connections.length <= 1,\n          disabled: connections.length === 0,\n        },\n        { type: \"separator\", label: \"History\" },\n        ...connections.map((c) => ({\n          label: (\n            <HStack space={2}>\n              {formatDistanceToNowStrict(`${c.createdAt}Z`)} ago &bull;{\" \"}\n              <span className=\"font-mono text-sm\">{c.elapsed}ms</span>\n            </HStack>\n          ),\n          leftSlot: activeConnection?.id === c.id ? <Icon icon=\"check\" /> : <Icon icon=\"empty\" />,\n          onSelect: () => onPinnedConnectionId(c.id),\n        })),\n      ]}\n    >\n      <IconButton\n        title=\"Show connection history\"\n        icon={activeConnection?.id === latestConnectionId ? \"history\" : \"pin\"}\n        className=\"m-0.5 text-text-subtle\"\n        size=\"sm\"\n        iconSize=\"md\"\n      />\n    </Dropdown>\n  );\n}\n"
  },
  {
    "path": "src-web/components/RedirectToLatestWorkspace.tsx",
    "content": "import { workspacesAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useEffect } from \"react\";\nimport { getRecentCookieJars } from \"../hooks/useRecentCookieJars\";\nimport { getRecentEnvironments } from \"../hooks/useRecentEnvironments\";\nimport { getRecentRequests } from \"../hooks/useRecentRequests\";\nimport { useRecentWorkspaces } from \"../hooks/useRecentWorkspaces\";\nimport { fireAndForget } from \"../lib/fireAndForget\";\nimport { router } from \"../lib/router\";\n\nexport function RedirectToLatestWorkspace() {\n  const workspaces = useAtomValue(workspacesAtom);\n  const recentWorkspaces = useRecentWorkspaces();\n\n  useEffect(() => {\n    if (workspaces.length === 0 || recentWorkspaces == null) {\n      console.log(\"No workspaces found to redirect to. Skipping.\", {\n        workspaces,\n        recentWorkspaces,\n      });\n      return;\n    }\n\n    fireAndForget(\n      (async () => {\n        const workspaceId = recentWorkspaces[0] ?? workspaces[0]?.id ?? \"n/a\";\n        const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? null;\n        const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? null;\n        const requestId = (await getRecentRequests(workspaceId))[0] ?? null;\n        const params = { workspaceId };\n        const search = {\n          cookie_jar_id: cookieJarId,\n          environment_id: environmentId,\n          request_id: requestId,\n        };\n\n        console.log(\"Redirecting to workspace\", params, search);\n        await router.navigate({ to: \"/workspaces/$workspaceId\", params, search });\n      })(),\n    );\n  }, [recentWorkspaces, workspaces, workspaces.length]);\n\n  return null;\n}\n"
  },
  {
    "path": "src-web/components/RequestBodyViewer.tsx",
    "content": "import type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { lazy, Suspense } from \"react\";\nimport { useHttpRequestBody } from \"../hooks/useHttpRequestBody\";\nimport { getMimeTypeFromContentType, languageFromContentType } from \"../lib/contentType\";\nimport { LoadingIcon } from \"./core/LoadingIcon\";\nimport { EmptyStateText } from \"./EmptyStateText\";\nimport { AudioViewer } from \"./responseViewers/AudioViewer\";\nimport { CsvViewer } from \"./responseViewers/CsvViewer\";\nimport { ImageViewer } from \"./responseViewers/ImageViewer\";\nimport { MultipartViewer } from \"./responseViewers/MultipartViewer\";\nimport { SvgViewer } from \"./responseViewers/SvgViewer\";\nimport { TextViewer } from \"./responseViewers/TextViewer\";\nimport { VideoViewer } from \"./responseViewers/VideoViewer\";\nimport { WebPageViewer } from \"./responseViewers/WebPageViewer\";\n\nconst PdfViewer = lazy(() =>\n  import(\"./responseViewers/PdfViewer\").then((m) => ({ default: m.PdfViewer })),\n);\n\ninterface Props {\n  response: HttpResponse;\n}\n\nexport function RequestBodyViewer({ response }: Props) {\n  return <RequestBodyViewerInner key={response.id} response={response} />;\n}\n\nfunction RequestBodyViewerInner({ response }: Props) {\n  const { data, isLoading, error } = useHttpRequestBody(response);\n\n  if (isLoading) {\n    return (\n      <EmptyStateText>\n        <LoadingIcon />\n      </EmptyStateText>\n    );\n  }\n\n  if (error) {\n    return <EmptyStateText>Error loading request body: {error.message}</EmptyStateText>;\n  }\n\n  if (data?.bodyText == null || data.bodyText.length === 0) {\n    return <EmptyStateText>No request body</EmptyStateText>;\n  }\n\n  const { bodyText, body } = data;\n\n  // Try to detect language from content-type header that was sent\n  const contentTypeHeader = response.requestHeaders.find(\n    (h) => h.name.toLowerCase() === \"content-type\",\n  );\n  const contentType = contentTypeHeader?.value ?? null;\n  const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null;\n  const language = languageFromContentType(contentType, bodyText);\n\n  // Route to appropriate viewer based on content type\n  if (mimeType?.match(/^multipart/i)) {\n    const boundary = contentType?.split(\"boundary=\")[1] ?? \"unknown\";\n    // Create a copy because parseMultipart may detach the buffer\n    const bodyCopy = new Uint8Array(body);\n    return (\n      <MultipartViewer data={bodyCopy} boundary={boundary} idPrefix={`request.${response.id}`} />\n    );\n  }\n\n  if (mimeType?.match(/^image\\/svg/i)) {\n    return <SvgViewer text={bodyText} />;\n  }\n\n  if (mimeType?.match(/^image/i)) {\n    return <ImageViewer data={body.buffer} />;\n  }\n\n  if (mimeType?.match(/^audio/i)) {\n    return <AudioViewer data={body} />;\n  }\n\n  if (mimeType?.match(/^video/i)) {\n    return <VideoViewer data={body} />;\n  }\n\n  if (mimeType?.match(/csv|tab-separated/i)) {\n    return <CsvViewer text={bodyText} />;\n  }\n\n  if (mimeType?.match(/^text\\/html/i)) {\n    return <WebPageViewer html={bodyText} />;\n  }\n\n  if (mimeType?.match(/pdf/i)) {\n    return (\n      <Suspense fallback={<LoadingIcon />}>\n        <PdfViewer data={body} />\n      </Suspense>\n    );\n  }\n\n  return (\n    <TextViewer text={bodyText} language={language} stateKey={`request.body.${response.id}`} />\n  );\n}\n"
  },
  {
    "path": "src-web/components/RequestMethodDropdown.tsx",
    "content": "import type { HttpRequest } from \"@yaakapp-internal/models\";\nimport { patchModel } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { memo, useCallback, useMemo } from \"react\";\nimport { showPrompt } from \"../lib/prompt\";\nimport { Button } from \"./core/Button\";\nimport type { DropdownItem } from \"./core/Dropdown\";\nimport { HttpMethodTag, HttpMethodTagRaw } from \"./core/HttpMethodTag\";\nimport { Icon } from \"./core/Icon\";\nimport type { RadioDropdownItem } from \"./core/RadioDropdown\";\nimport { RadioDropdown } from \"./core/RadioDropdown\";\n\ntype Props = {\n  request: HttpRequest;\n  className?: string;\n};\n\nconst radioItems: RadioDropdownItem<string>[] = [\n  \"GET\",\n  \"PUT\",\n  \"POST\",\n  \"PATCH\",\n  \"DELETE\",\n  \"OPTIONS\",\n  \"QUERY\",\n  \"HEAD\",\n].map((m) => ({\n  value: m,\n  label: <HttpMethodTagRaw method={m} />,\n}));\n\nexport const RequestMethodDropdown = memo(function RequestMethodDropdown({\n  request,\n  className,\n}: Props) {\n  const handleChange = useCallback(\n    async (method: string) => {\n      await patchModel(request, { method });\n    },\n    [request],\n  );\n\n  const itemsAfter = useMemo<DropdownItem[]>(\n    () => [\n      {\n        key: \"custom\",\n        label: \"CUSTOM\",\n        leftSlot: <Icon icon=\"sparkles\" />,\n        onSelect: async () => {\n          const newMethod = await showPrompt({\n            id: \"custom-method\",\n            label: \"Http Method\",\n            title: \"Custom Method\",\n            confirmText: \"Save\",\n            description: \"Enter a custom method name\",\n            placeholder: \"CUSTOM\",\n          });\n          if (newMethod == null) return;\n          await handleChange(newMethod);\n        },\n      },\n    ],\n    [handleChange],\n  );\n\n  return (\n    <RadioDropdown\n      value={request.method}\n      items={radioItems}\n      itemsAfter={itemsAfter}\n      onChange={handleChange}\n    >\n      <Button size=\"xs\" className={classNames(className, \"text-text-subtle hover:text\")}>\n        <HttpMethodTag request={request} noAlias />\n      </Button>\n    </RadioDropdown>\n  );\n});\n"
  },
  {
    "path": "src-web/components/ResizeHandle.tsx",
    "content": "import classNames from \"classnames\";\nimport type { CSSProperties, MouseEvent as ReactMouseEvent } from \"react\";\nimport { useCallback, useRef, useState } from \"react\";\n\nconst START_DISTANCE = 7;\n\nexport interface ResizeHandleEvent {\n  x: number;\n  y: number;\n  xStart: number;\n  yStart: number;\n}\n\ninterface Props {\n  style?: CSSProperties;\n  className?: string;\n  onResizeStart?: () => void;\n  onResizeEnd?: () => void;\n  onResizeMove?: (e: ResizeHandleEvent) => void;\n  onReset?: () => void;\n  side: \"left\" | \"right\" | \"top\";\n  justify: \"center\" | \"end\" | \"start\";\n}\n\nexport function ResizeHandle({\n  style,\n  justify,\n  className,\n  onResizeStart,\n  onResizeEnd,\n  onResizeMove,\n  onReset,\n  side,\n}: Props) {\n  const vertical = side === \"top\";\n  const [isResizing, setIsResizing] = useState<boolean>(false);\n  const moveState = useRef<{\n    move: (e: MouseEvent) => void;\n    up: (e: MouseEvent) => void;\n    calledStart: boolean;\n    xStart: number;\n    yStart: number;\n  } | null>(null);\n\n  const handlePointerDown = useCallback(\n    (e: ReactMouseEvent<HTMLDivElement>) => {\n      function move(e: MouseEvent) {\n        if (moveState.current == null) return;\n\n        const xDistance = moveState.current.xStart - e.clientX;\n        const yDistance = moveState.current.yStart - e.clientY;\n        const distance = Math.abs(vertical ? yDistance : xDistance);\n        if (moveState.current.calledStart) {\n          onResizeMove?.({\n            x: e.clientX,\n            y: e.clientY,\n            xStart: moveState.current.xStart,\n            yStart: moveState.current.yStart,\n          });\n        } else if (distance > START_DISTANCE) {\n          onResizeStart?.();\n          moveState.current.calledStart = true;\n          setIsResizing(true);\n        }\n      }\n\n      function up() {\n        setIsResizing(false);\n        moveState.current = null;\n        document.documentElement.removeEventListener(\"mousemove\", move);\n        document.documentElement.removeEventListener(\"mouseup\", up);\n        onResizeEnd?.();\n      }\n\n      moveState.current = { calledStart: false, xStart: e.clientX, yStart: e.clientY, move, up };\n\n      document.documentElement.addEventListener(\"mousemove\", move);\n      document.documentElement.addEventListener(\"mouseup\", up);\n    },\n    [onResizeEnd, onResizeMove, onResizeStart, vertical],\n  );\n\n  return (\n    <div\n      aria-hidden\n      style={style}\n      onDoubleClick={onReset}\n      onPointerDown={handlePointerDown}\n      className={classNames(\n        className,\n        \"group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full\",\n        // 'bg-info', // For debugging\n        vertical ? \"w-full h-1.5 cursor-row-resize\" : \"h-full w-1.5 cursor-col-resize\",\n        justify === \"center\" && \"justify-center\",\n        justify === \"end\" && \"justify-end\",\n        justify === \"start\" && \"justify-start\",\n        side === \"right\" && \"right-0\",\n        side === \"left\" && \"left-0\",\n        side === \"top\" && \"top-0\",\n      )}\n    >\n      {/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */}\n      {isResizing && (\n        <div\n          className={classNames(\n            // 'bg-[rgba(255,0,0,0.1)]', // For debugging\n            \"fixed -left-[100vw] -right-[100vw] -top-[100vh] -bottom-[100vh]\",\n            vertical && \"cursor-row-resize\",\n            !vertical && \"cursor-col-resize\",\n          )}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/ResponseCookies.tsx",
    "content": "import type { HttpResponse } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { useMemo } from \"react\";\nimport type { JSX } from \"react/jsx-runtime\";\nimport { useHttpResponseEvents } from \"../hooks/useHttpResponseEvents\";\nimport { CountBadge } from \"./core/CountBadge\";\nimport { DetailsBanner } from \"./core/DetailsBanner\";\nimport { KeyValueRow, KeyValueRows } from \"./core/KeyValueRow\";\n\ninterface Props {\n  response: HttpResponse;\n}\n\ninterface ParsedCookie {\n  name: string;\n  value: string;\n  domain?: string;\n  path?: string;\n  expires?: string;\n  maxAge?: string;\n  secure?: boolean;\n  httpOnly?: boolean;\n  sameSite?: string;\n  isDeleted?: boolean;\n}\n\nfunction parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> {\n  // Parse \"Cookie: name=value; name2=value2\" format\n  return cookieHeader.split(\";\").map((pair) => {\n    const [name = \"\", ...valueParts] = pair.split(\"=\");\n    return {\n      name: name.trim(),\n      value: valueParts.join(\"=\").trim(),\n    };\n  });\n}\n\nfunction parseSetCookieHeader(setCookieHeader: string): ParsedCookie {\n  // Parse \"Set-Cookie: name=value; Domain=...; Path=...\" format\n  const parts = setCookieHeader.split(\";\").map((p) => p.trim());\n  const [nameValue = \"\", ...attributes] = parts;\n  const [name = \"\", ...valueParts] = nameValue.split(\"=\");\n\n  const cookie: ParsedCookie = {\n    name: name.trim(),\n    value: valueParts.join(\"=\").trim(),\n  };\n\n  for (const attr of attributes) {\n    const [key = \"\", val] = attr.split(\"=\").map((s) => s.trim());\n    const lowerKey = key.toLowerCase();\n\n    if (lowerKey === \"domain\") cookie.domain = val;\n    else if (lowerKey === \"path\") cookie.path = val;\n    else if (lowerKey === \"expires\") cookie.expires = val;\n    else if (lowerKey === \"max-age\") cookie.maxAge = val;\n    else if (lowerKey === \"secure\") cookie.secure = true;\n    else if (lowerKey === \"httponly\") cookie.httpOnly = true;\n    else if (lowerKey === \"samesite\") cookie.sameSite = val;\n  }\n\n  // Detect if cookie is being deleted\n  if (cookie.maxAge !== undefined) {\n    const maxAgeNum = Number.parseInt(cookie.maxAge, 10);\n    if (!Number.isNaN(maxAgeNum) && maxAgeNum <= 0) {\n      cookie.isDeleted = true;\n    }\n  } else if (cookie.expires !== undefined) {\n    // Check if expires date is in the past\n    try {\n      const expiresDate = new Date(cookie.expires);\n      if (expiresDate.getTime() < Date.now()) {\n        cookie.isDeleted = true;\n      }\n    } catch {\n      // Invalid date, ignore\n    }\n  }\n\n  return cookie;\n}\n\nexport function ResponseCookies({ response }: Props) {\n  const { data: events } = useHttpResponseEvents(response);\n\n  const { sentCookies, receivedCookies } = useMemo(() => {\n    if (!events) return { sentCookies: [], receivedCookies: [] };\n\n    // Use Maps to deduplicate by cookie name (latest value wins)\n    const sentMap = new Map<string, { name: string; value: string }>();\n    const receivedMap = new Map<string, ParsedCookie>();\n\n    for (const event of events) {\n      const e = event.event;\n\n      // Cookie headers sent (header_up with name=cookie)\n      if (e.type === \"header_up\" && e.name.toLowerCase() === \"cookie\") {\n        const cookies = parseCookieHeader(e.value);\n        for (const cookie of cookies) {\n          sentMap.set(cookie.name, cookie);\n        }\n      }\n\n      // Set-Cookie headers received (header_down with name=set-cookie)\n      if (e.type === \"header_down\" && e.name.toLowerCase() === \"set-cookie\") {\n        const cookie = parseSetCookieHeader(e.value);\n        receivedMap.set(cookie.name, cookie);\n      }\n    }\n\n    return {\n      sentCookies: Array.from(sentMap.values()),\n      receivedCookies: Array.from(receivedMap.values()),\n    };\n  }, [events]);\n\n  return (\n    <div className=\"overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5\">\n      <DetailsBanner\n        defaultOpen\n        storageKey={`${response.requestId}.sent_cookies`}\n        summary={\n          <h2 className=\"flex items-center\">\n            Sent Cookies <CountBadge showZero count={sentCookies.length} />\n          </h2>\n        }\n      >\n        {sentCookies.length === 0 ? (\n          <NoCookies />\n        ) : (\n          <KeyValueRows>\n            {sentCookies.map((cookie, i) => (\n              // oxlint-disable-next-line react/no-array-index-key\n              <KeyValueRow labelColor=\"primary\" key={i} label={cookie.name}>\n                {cookie.value}\n              </KeyValueRow>\n            ))}\n          </KeyValueRows>\n        )}\n      </DetailsBanner>\n\n      <DetailsBanner\n        defaultOpen\n        storageKey={`${response.requestId}.received_cookies`}\n        summary={\n          <h2 className=\"flex items-center\">\n            Received Cookies <CountBadge showZero count={receivedCookies.length} />\n          </h2>\n        }\n      >\n        {receivedCookies.length === 0 ? (\n          <NoCookies />\n        ) : (\n          <div className=\"flex flex-col gap-4\">\n            {receivedCookies.map((cookie, i) => (\n              // oxlint-disable-next-line react/no-array-index-key\n              <div key={i} className=\"flex flex-col gap-1\">\n                <div className=\"flex items-center gap-2 my-1\">\n                  <span\n                    className={classNames(\n                      \"font-mono text-editor select-auto cursor-auto\",\n                      cookie.isDeleted ? \"line-through opacity-60 text-text-subtle\" : \"text-text\",\n                    )}\n                  >\n                    {cookie.name}\n                    <span className=\"text-text-subtlest select-auto cursor-auto mx-0.5\">=</span>\n                    {cookie.value}\n                  </span>\n                  {cookie.isDeleted && (\n                    <span className=\"text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded\">\n                      Deleted\n                    </span>\n                  )}\n                </div>\n                <KeyValueRows>\n                  {[\n                    cookie.domain && (\n                      <KeyValueRow labelColor=\"info\" label=\"Domain\" key=\"domain\">\n                        {cookie.domain}\n                      </KeyValueRow>\n                    ),\n                    cookie.path && (\n                      <KeyValueRow labelColor=\"info\" label=\"Path\" key=\"path\">\n                        {cookie.path}\n                      </KeyValueRow>\n                    ),\n                    cookie.expires && (\n                      <KeyValueRow labelColor=\"info\" label=\"Expires\" key=\"expires\">\n                        {cookie.expires}\n                      </KeyValueRow>\n                    ),\n                    cookie.maxAge && (\n                      <KeyValueRow labelColor=\"info\" label=\"Max-Age\" key=\"maxAge\">\n                        {cookie.maxAge}\n                      </KeyValueRow>\n                    ),\n                    cookie.secure && (\n                      <KeyValueRow labelColor=\"info\" label=\"Secure\" key=\"secure\">\n                        true\n                      </KeyValueRow>\n                    ),\n                    cookie.httpOnly && (\n                      <KeyValueRow labelColor=\"info\" label=\"HttpOnly\" key=\"httpOnly\">\n                        true\n                      </KeyValueRow>\n                    ),\n                    cookie.sameSite && (\n                      <KeyValueRow labelColor=\"info\" label=\"SameSite\" key=\"sameSite\">\n                        {cookie.sameSite}\n                      </KeyValueRow>\n                    ),\n                  ].filter((item): item is JSX.Element => Boolean(item))}\n                </KeyValueRows>\n              </div>\n            ))}\n          </div>\n        )}\n      </DetailsBanner>\n    </div>\n  );\n}\n\nfunction NoCookies() {\n  return <span className=\"text-text-subtlest text-sm italic\">No Cookies</span>;\n}\n"
  },
  {
    "path": "src-web/components/ResponseHeaders.tsx",
    "content": "import { openUrl } from \"@tauri-apps/plugin-opener\";\nimport type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { useMemo } from \"react\";\nimport { CountBadge } from \"./core/CountBadge\";\nimport { DetailsBanner } from \"./core/DetailsBanner\";\nimport { IconButton } from \"./core/IconButton\";\nimport { KeyValueRow, KeyValueRows } from \"./core/KeyValueRow\";\n\ninterface Props {\n  response: HttpResponse;\n}\n\nexport function ResponseHeaders({ response }: Props) {\n  const responseHeaders = useMemo(\n    () =>\n      [...response.headers].sort((a, b) =>\n        a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),\n      ),\n    [response.headers],\n  );\n  const requestHeaders = useMemo(\n    () =>\n      [...response.requestHeaders].sort((a, b) =>\n        a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),\n      ),\n    [response.requestHeaders],\n  );\n  return (\n    <div className=\"overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5\">\n      <DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>\n        <KeyValueRows>\n          <KeyValueRow labelColor=\"secondary\" label=\"Request URL\">\n            <div className=\"flex items-center gap-1\">\n              <span className=\"select-text cursor-text\">{response.url}</span>\n              <IconButton\n                iconSize=\"sm\"\n                className=\"inline-block w-auto !h-auto opacity-50 hover:opacity-100\"\n                icon=\"external_link\"\n                onClick={() => openUrl(response.url)}\n                title=\"Open in browser\"\n              />\n            </div>\n          </KeyValueRow>\n          <KeyValueRow labelColor=\"secondary\" label=\"Remote Address\">\n            {response.remoteAddr ?? <span className=\"text-text-subtlest\">--</span>}\n          </KeyValueRow>\n          <KeyValueRow labelColor=\"secondary\" label=\"Version\">\n            {response.version ?? <span className=\"text-text-subtlest\">--</span>}\n          </KeyValueRow>\n        </KeyValueRows>\n      </DetailsBanner>\n      <DetailsBanner\n        storageKey={`${response.requestId}.request_headers`}\n        summary={\n          <h2 className=\"flex items-center\">\n            Request Headers <CountBadge showZero count={requestHeaders.length} />\n          </h2>\n        }\n      >\n        {requestHeaders.length === 0 ? (\n          <NoHeaders />\n        ) : (\n          <KeyValueRows>\n            {requestHeaders.map((h, i) => (\n              // oxlint-disable-next-line react/no-array-index-key\n              <KeyValueRow labelColor=\"primary\" key={i} label={h.name}>\n                {h.value}\n              </KeyValueRow>\n            ))}\n          </KeyValueRows>\n        )}\n      </DetailsBanner>\n      <DetailsBanner\n        defaultOpen\n        storageKey={`${response.requestId}.response_headers`}\n        summary={\n          <h2 className=\"flex items-center\">\n            Response Headers <CountBadge showZero count={responseHeaders.length} />\n          </h2>\n        }\n      >\n        {responseHeaders.length === 0 ? (\n          <NoHeaders />\n        ) : (\n          <KeyValueRows>\n            {responseHeaders.map((h, i) => (\n              // oxlint-disable-next-line react/no-array-index-key\n              <KeyValueRow labelColor=\"info\" key={i} label={h.name}>\n                {h.value}\n              </KeyValueRow>\n            ))}\n          </KeyValueRows>\n        )}\n      </DetailsBanner>\n    </div>\n  );\n}\n\nfunction NoHeaders() {\n  return <span className=\"text-text-subtlest text-sm italic\">No Headers</span>;\n}\n"
  },
  {
    "path": "src-web/components/ResponseInfo.tsx",
    "content": "import { openUrl } from \"@tauri-apps/plugin-opener\";\nimport type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { IconButton } from \"./core/IconButton\";\nimport { KeyValueRow, KeyValueRows } from \"./core/KeyValueRow\";\n\ninterface Props {\n  response: HttpResponse;\n}\n\nexport function ResponseInfo({ response }: Props) {\n  return (\n    <div className=\"overflow-auto h-full pb-4\">\n      <KeyValueRows>\n        <KeyValueRow labelColor=\"info\" label=\"Version\">\n          {response.version ?? <span className=\"text-text-subtlest\">--</span>}\n        </KeyValueRow>\n        <KeyValueRow labelColor=\"info\" label=\"Remote Address\">\n          {response.remoteAddr ?? <span className=\"text-text-subtlest\">--</span>}\n        </KeyValueRow>\n        <KeyValueRow\n          labelColor=\"info\"\n          label={\n            <div className=\"flex items-center\">\n              URL\n              <IconButton\n                iconSize=\"sm\"\n                className=\"inline-block w-auto ml-1 !h-auto opacity-50 hover:opacity-100\"\n                icon=\"external_link\"\n                onClick={() => openUrl(response.url)}\n                title=\"Open in browser\"\n              />\n            </div>\n          }\n        >\n          {\n            <div className=\"flex\">\n              <span className=\"select-text cursor-text\">{response.url}</span>\n            </div>\n          }\n        </KeyValueRow>\n      </KeyValueRows>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/RouteError.tsx",
    "content": "import { Button } from \"./core/Button\";\nimport { DetailsBanner } from \"./core/DetailsBanner\";\nimport { FormattedError } from \"./core/FormattedError\";\nimport { Heading } from \"./core/Heading\";\nimport { VStack } from \"./core/Stacks\";\n\nexport default function RouteError({ error }: { error: unknown }) {\n  console.log(\"Error\", error);\n  const stringified = JSON.stringify(error);\n  // oxlint-disable-next-line no-explicit-any\n  const message = (error as any).message ?? stringified;\n  const stack =\n    typeof error === \"object\" && error != null && \"stack\" in error ? String(error.stack) : null;\n  return (\n    <div className=\"flex items-center justify-center h-full\">\n      <VStack space={5} className=\"w-[50rem] !h-auto\">\n        <Heading>Route Error 🔥</Heading>\n        <FormattedError>\n          {message}\n          {stack && (\n            <DetailsBanner\n              color=\"secondary\"\n              className=\"mt-3 select-auto text-xs max-h-[40vh]\"\n              summary=\"Stack Trace\"\n            >\n              <div className=\"mt-2 text-xs\">{stack}</div>\n            </DetailsBanner>\n          )}\n        </FormattedError>\n        <VStack space={2}>\n          <Button\n            color=\"primary\"\n            onClick={async () => {\n              window.location.assign(\"/\");\n            }}\n          >\n            Go Home\n          </Button>\n          <Button color=\"info\" onClick={() => window.location.reload()}>\n            Refresh\n          </Button>\n        </VStack>\n      </VStack>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/SelectFile.tsx",
    "content": "import { getCurrentWebviewWindow } from \"@tauri-apps/api/webviewWindow\";\nimport { open } from \"@tauri-apps/plugin-dialog\";\nimport classNames from \"classnames\";\nimport mime from \"mime\";\nimport type { ReactNode } from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport type { ButtonProps } from \"./core/Button\";\nimport { Button } from \"./core/Button\";\nimport { IconButton } from \"./core/IconButton\";\nimport { IconTooltip } from \"./core/IconTooltip\";\nimport { Label } from \"./core/Label\";\nimport { HStack } from \"./core/Stacks\";\n\ntype Props = Omit<ButtonProps, \"type\"> & {\n  onChange: (value: { filePath: string | null; contentType: string | null }) => void;\n  filePath: string | null;\n  nameOverride?: string | null;\n  directory?: boolean;\n  inline?: boolean;\n  noun?: string;\n  help?: ReactNode;\n  label?: ReactNode;\n};\n\n// Special character to insert ltr text in rtl element\nconst rtlEscapeChar = <>&#x200E;</>;\n\nexport function SelectFile({\n  onChange,\n  filePath,\n  inline,\n  className,\n  directory,\n  noun,\n  nameOverride,\n  size = \"sm\",\n  label,\n  help,\n  ...props\n}: Props) {\n  const handleClick = async () => {\n    const filePath = await open({\n      title: directory ? \"Select Folder\" : \"Select File\",\n      multiple: false,\n      directory,\n    });\n    if (filePath == null) return;\n    const contentType = filePath ? mime.getType(filePath) : null;\n    onChange({ filePath, contentType });\n  };\n\n  const handleClear = async () => {\n    onChange({ filePath: null, contentType: null });\n  };\n\n  const itemLabel = noun ?? (directory ? \"Folder\" : \"File\");\n  const selectOrChange = (filePath ? \"Change \" : \"Select \") + itemLabel;\n  const [isHovering, setIsHovering] = useState(false);\n  const ref = useRef<HTMLDivElement>(null);\n\n  // Listen for dropped files on the element\n  // NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie\n  //  as browser drag-n-drop.\n  useEffect(() => {\n    let unlisten: (() => void) | undefined;\n    const setup = async () => {\n      const webview = getCurrentWebviewWindow();\n      unlisten = await webview.onDragDropEvent((event) => {\n        if (event.payload.type === \"over\") {\n          const p = event.payload.position;\n          const r = ref.current?.getBoundingClientRect();\n          if (r == null) return;\n          const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;\n          console.log(\"IS OVER\", isOver);\n          setIsHovering(isOver);\n        } else if (event.payload.type === \"drop\" && isHovering) {\n          console.log(\"User dropped\", event.payload.paths);\n          const p = event.payload.paths[0];\n          if (p) onChange({ filePath: p, contentType: null });\n          setIsHovering(false);\n        } else {\n          console.log(\"File drop cancelled\");\n          setIsHovering(false);\n        }\n      });\n    };\n    setup().catch(console.error);\n    return () => {\n      if (unlisten) unlisten();\n    };\n  }, [isHovering, onChange]);\n\n  const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;\n\n  return (\n    <div ref={ref} className=\"w-full\">\n      {label && (\n        <Label htmlFor={null} help={help}>\n          {label}\n        </Label>\n      )}\n      <HStack className=\"relative justify-stretch overflow-hidden\">\n        <Button\n          className={classNames(\n            className,\n            \"rtl mr-1.5\",\n            inline && \"w-full\",\n            filePath && inline && \"font-mono text-xs\",\n            isHovering && \"!border-notice\",\n          )}\n          color={isHovering ? \"primary\" : \"secondary\"}\n          onClick={handleClick}\n          size={size}\n          {...props}\n        >\n          {rtlEscapeChar}\n          {inline ? filePathWithNameOverride || selectOrChange : selectOrChange}\n        </Button>\n\n        {!inline && (\n          <>\n            {filePath && (\n              <IconButton\n                size={size === \"auto\" ? \"md\" : size}\n                variant=\"border\"\n                icon=\"x\"\n                title={`Unset ${itemLabel}`}\n                onClick={handleClear}\n              />\n            )}\n            <div\n              className={classNames(\n                \"truncate rtl pl-1.5 pr-3 text-text\",\n                filePath && \"font-mono\",\n                size === \"xs\" && filePath && \"text-xs\",\n                size === \"sm\" && filePath && \"text-sm\",\n              )}\n            >\n              {rtlEscapeChar}\n              {filePath ?? `No ${itemLabel.toLowerCase()} selected`}\n            </div>\n            {filePath == null && help && !label && <IconTooltip content={help} />}\n          </>\n        )}\n      </HStack>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/Settings/Settings.tsx",
    "content": "import { useSearch } from \"@tanstack/react-router\";\nimport { getCurrentWebviewWindow } from \"@tauri-apps/api/webviewWindow\";\nimport { type } from \"@tauri-apps/plugin-os\";\nimport { useLicense } from \"@yaakapp-internal/license\";\nimport { pluginsAtom, settingsAtom } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport { useKeyPressEvent } from \"react-use\";\nimport { appInfo } from \"../../lib/appInfo\";\nimport { capitalize } from \"../../lib/capitalize\";\nimport { CountBadge } from \"../core/CountBadge\";\nimport { Icon } from \"../core/Icon\";\nimport { HStack } from \"../core/Stacks\";\nimport { TabContent, type TabItem, Tabs } from \"../core/Tabs/Tabs\";\nimport { HeaderSize } from \"../HeaderSize\";\nimport { SettingsCertificates } from \"./SettingsCertificates\";\nimport { SettingsGeneral } from \"./SettingsGeneral\";\nimport { SettingsHotkeys } from \"./SettingsHotkeys\";\nimport { SettingsInterface } from \"./SettingsInterface\";\nimport { SettingsLicense } from \"./SettingsLicense\";\nimport { SettingsPlugins } from \"./SettingsPlugins\";\nimport { SettingsProxy } from \"./SettingsProxy\";\nimport { SettingsTheme } from \"./SettingsTheme\";\n\ninterface Props {\n  hide?: () => void;\n}\n\nconst TAB_GENERAL = \"general\";\nconst TAB_INTERFACE = \"interface\";\nconst TAB_THEME = \"theme\";\nconst TAB_SHORTCUTS = \"shortcuts\";\nconst TAB_PROXY = \"proxy\";\nconst TAB_CERTIFICATES = \"certificates\";\nconst TAB_PLUGINS = \"plugins\";\nconst TAB_LICENSE = \"license\";\nconst tabs = [\n  TAB_GENERAL,\n  TAB_THEME,\n  TAB_INTERFACE,\n  TAB_SHORTCUTS,\n  TAB_PLUGINS,\n  TAB_CERTIFICATES,\n  TAB_PROXY,\n  TAB_LICENSE,\n] as const;\nexport type SettingsTab = (typeof tabs)[number];\n\nexport default function Settings({ hide }: Props) {\n  const { tab: tabFromQuery } = useSearch({ from: \"/workspaces/$workspaceId/settings\" });\n  // Parse tab and subtab (e.g., \"plugins:installed\")\n  const [mainTab, subtab] = tabFromQuery?.split(\":\") ?? [];\n  const settings = useAtomValue(settingsAtom);\n  const plugins = useAtomValue(pluginsAtom);\n  const licenseCheck = useLicense();\n\n  // Close settings window on escape\n  // TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window\n  useKeyPressEvent(\"Escape\", async () => {\n    if (hide != null) {\n      // It's being shown in a dialog, so close the dialog\n      hide();\n    } else {\n      // It's being shown in a window, so close the window\n      await getCurrentWebviewWindow().close();\n    }\n  });\n\n  return (\n    <div className={classNames(\"grid grid-rows-[auto_minmax(0,1fr)] h-full\")}>\n      {hide ? (\n        <span />\n      ) : (\n        <HeaderSize\n          data-tauri-drag-region\n          ignoreControlsSpacing\n          onlyXWindowControl\n          size=\"md\"\n          className=\"x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold\"\n        >\n          <HStack\n            space={2}\n            justifyContent=\"center\"\n            className=\"w-full h-full grid grid-cols-[1fr_auto] pointer-events-none\"\n          >\n            <div className={classNames(type() === \"macos\" ? \"text-center\" : \"pl-2\")}>Settings</div>\n          </HStack>\n        </HeaderSize>\n      )}\n      <Tabs\n        layout=\"horizontal\"\n        defaultValue={mainTab || tabFromQuery}\n        addBorders\n        tabListClassName=\"min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3\"\n        label=\"Settings\"\n        tabs={tabs.map(\n          (value): TabItem => ({\n            value,\n            label: capitalize(value),\n            hidden: !appInfo.featureLicense && value === TAB_LICENSE,\n            leftSlot:\n              value === TAB_GENERAL ? (\n                <Icon icon=\"settings\" className=\"text-secondary\" />\n              ) : value === TAB_THEME ? (\n                <Icon icon=\"palette\" className=\"text-secondary\" />\n              ) : value === TAB_INTERFACE ? (\n                <Icon icon=\"columns_2\" className=\"text-secondary\" />\n              ) : value === TAB_SHORTCUTS ? (\n                <Icon icon=\"keyboard\" className=\"text-secondary\" />\n              ) : value === TAB_CERTIFICATES ? (\n                <Icon icon=\"shield_check\" className=\"text-secondary\" />\n              ) : value === TAB_PROXY ? (\n                <Icon icon=\"wifi\" className=\"text-secondary\" />\n              ) : value === TAB_PLUGINS ? (\n                <Icon icon=\"puzzle\" className=\"text-secondary\" />\n              ) : value === TAB_LICENSE ? (\n                <Icon icon=\"key_round\" className=\"text-secondary\" />\n              ) : null,\n            rightSlot:\n              value === TAB_CERTIFICATES ? (\n                <CountBadge count={settings.clientCertificates.length} />\n              ) : value === TAB_PLUGINS ? (\n                <CountBadge count={plugins.filter((p) => p.source !== \"bundled\").length} />\n              ) : value === TAB_PROXY && settings.proxy?.type === \"enabled\" ? (\n                <CountBadge count />\n              ) : value === TAB_LICENSE && licenseCheck.check.data?.status === \"personal_use\" ? (\n                <CountBadge count color=\"notice\" />\n              ) : null,\n          }),\n        )}\n      >\n        <TabContent value={TAB_GENERAL} className=\"overflow-y-auto h-full px-6 !py-4\">\n          <SettingsGeneral />\n        </TabContent>\n        <TabContent value={TAB_INTERFACE} className=\"overflow-y-auto h-full px-6 !py-4\">\n          <SettingsInterface />\n        </TabContent>\n        <TabContent value={TAB_THEME} className=\"overflow-y-auto h-full px-6 !py-4\">\n          <SettingsTheme />\n        </TabContent>\n        <TabContent value={TAB_SHORTCUTS} className=\"overflow-y-auto h-full px-6 !py-4\">\n          <SettingsHotkeys />\n        </TabContent>\n        <TabContent value={TAB_PLUGINS} className=\"h-full grid grid-rows-1\">\n          <SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />\n        </TabContent>\n        <TabContent value={TAB_PROXY} className=\"overflow-y-auto h-full px-6 !py-4\">\n          <SettingsProxy />\n        </TabContent>\n        <TabContent value={TAB_CERTIFICATES} className=\"overflow-y-auto h-full px-6 !py-4\">\n          <SettingsCertificates />\n        </TabContent>\n        <TabContent value={TAB_LICENSE} className=\"overflow-y-auto h-full px-6 !py-4\">\n          <SettingsLicense />\n        </TabContent>\n      </Tabs>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/Settings/SettingsCertificates.tsx",
    "content": "import type { ClientCertificate } from \"@yaakapp-internal/models\";\nimport { patchModel, settingsAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useRef } from \"react\";\nimport { showConfirmDelete } from \"../../lib/confirm\";\nimport { Button } from \"../core/Button\";\nimport { Checkbox } from \"../core/Checkbox\";\nimport { DetailsBanner } from \"../core/DetailsBanner\";\nimport { Heading } from \"../core/Heading\";\nimport { IconButton } from \"../core/IconButton\";\nimport { InlineCode } from \"../core/InlineCode\";\nimport { PlainInput } from \"../core/PlainInput\";\nimport { Separator } from \"../core/Separator\";\nimport { HStack, VStack } from \"../core/Stacks\";\nimport { SelectFile } from \"../SelectFile\";\n\nfunction createEmptyCertificate(): ClientCertificate {\n  return {\n    host: \"\",\n    port: null,\n    crtFile: null,\n    keyFile: null,\n    pfxFile: null,\n    passphrase: null,\n    enabled: true,\n  };\n}\n\ninterface CertificateEditorProps {\n  certificate: ClientCertificate;\n  index: number;\n  onUpdate: (index: number, cert: ClientCertificate) => void;\n  onRemove: (index: number) => void;\n}\n\nfunction CertificateEditor({ certificate, index, onUpdate, onRemove }: CertificateEditorProps) {\n  const updateField = <K extends keyof ClientCertificate>(\n    field: K,\n    value: ClientCertificate[K],\n  ) => {\n    onUpdate(index, { ...certificate, [field]: value });\n  };\n\n  const hasPfx = Boolean(certificate.pfxFile && certificate.pfxFile.length > 0);\n  const hasCrtKey = Boolean(\n    (certificate.crtFile && certificate.crtFile.length > 0) ||\n    (certificate.keyFile && certificate.keyFile.length > 0),\n  );\n\n  // Determine certificate type for display\n  const certType = hasPfx ? \"PFX\" : hasCrtKey ? \"CERT\" : null;\n  const defaultOpen = useRef<boolean>(!certificate.host);\n\n  return (\n    <DetailsBanner\n      defaultOpen={defaultOpen.current}\n      summary={\n        <HStack alignItems=\"center\" justifyContent=\"between\" space={2} className=\"w-full\">\n          <HStack space={1.5}>\n            <Checkbox\n              className=\"ml-1\"\n              checked={certificate.enabled ?? true}\n              title={certificate.enabled ? \"Disable certificate\" : \"Enable certificate\"}\n              hideLabel\n              onChange={(enabled) => updateField(\"enabled\", enabled)}\n            />\n\n            {certificate.host ? (\n              <InlineCode>\n                {certificate.host || <>&nbsp;</>}\n                {certificate.port != null && `:${certificate.port}`}\n              </InlineCode>\n            ) : (\n              <span className=\"italic text-sm text-text-subtlest\">Configure Certificate</span>\n            )}\n            {certType && <InlineCode>{certType}</InlineCode>}\n          </HStack>\n          <IconButton\n            icon=\"trash\"\n            size=\"sm\"\n            title=\"Remove certificate\"\n            className=\"text-text-subtlest -mr-2\"\n            onClick={() => onRemove(index)}\n          />\n        </HStack>\n      }\n    >\n      <VStack space={3} className=\"mt-2\">\n        <HStack space={2} alignItems=\"end\">\n          <PlainInput\n            leftSlot={\n              <div className=\"bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1\">\n                https://\n              </div>\n            }\n            validate={(value) => {\n              if (!value) return false;\n              if (!/^[a-zA-Z0-9_.-]+$/.test(value)) return false;\n              return true;\n            }}\n            label=\"Host\"\n            placeholder=\"example.com\"\n            size=\"sm\"\n            required\n            defaultValue={certificate.host}\n            onChange={(host) => updateField(\"host\", host)}\n          />\n          <PlainInput\n            label=\"Port\"\n            hideLabel\n            validate={(value) => {\n              if (!value) return true;\n              if (Number.isNaN(parseInt(value, 10))) return false;\n              return true;\n            }}\n            placeholder=\"443\"\n            leftSlot={\n              <div className=\"bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1\">\n                :\n              </div>\n            }\n            size=\"sm\"\n            className=\"w-24\"\n            defaultValue={certificate.port?.toString() ?? \"\"}\n            onChange={(port) => updateField(\"port\", port ? parseInt(port, 10) : null)}\n          />\n        </HStack>\n\n        <Separator className=\"my-3\" />\n\n        <VStack space={2}>\n          <SelectFile\n            label=\"CRT File\"\n            noun=\"Cert\"\n            filePath={certificate.crtFile ?? null}\n            size=\"sm\"\n            disabled={hasPfx}\n            onChange={({ filePath }) => updateField(\"crtFile\", filePath)}\n          />\n          <SelectFile\n            label=\"KEY File\"\n            noun=\"Key\"\n            filePath={certificate.keyFile ?? null}\n            size=\"sm\"\n            disabled={hasPfx}\n            onChange={({ filePath }) => updateField(\"keyFile\", filePath)}\n          />\n        </VStack>\n\n        <Separator className=\"my-3\" />\n\n        <SelectFile\n          label=\"PFX File\"\n          noun=\"Key\"\n          filePath={certificate.pfxFile ?? null}\n          size=\"sm\"\n          disabled={hasCrtKey}\n          onChange={({ filePath }) => updateField(\"pfxFile\", filePath)}\n        />\n\n        <PlainInput\n          label=\"Passphrase\"\n          size=\"sm\"\n          type=\"password\"\n          defaultValue={certificate.passphrase ?? \"\"}\n          onChange={(passphrase) => updateField(\"passphrase\", passphrase || null)}\n        />\n      </VStack>\n    </DetailsBanner>\n  );\n}\n\nexport function SettingsCertificates() {\n  const settings = useAtomValue(settingsAtom);\n  const certificates = settings.clientCertificates ?? [];\n\n  const updateCertificates = async (newCertificates: ClientCertificate[]) => {\n    await patchModel(settings, { clientCertificates: newCertificates });\n  };\n\n  const handleAdd = async () => {\n    const newCert = createEmptyCertificate();\n    await updateCertificates([...certificates, newCert]);\n  };\n\n  const handleUpdate = async (index: number, cert: ClientCertificate) => {\n    const newCertificates = [...certificates];\n    newCertificates[index] = cert;\n    await updateCertificates(newCertificates);\n  };\n\n  const handleRemove = async (index: number) => {\n    const cert = certificates[index];\n    if (cert == null) return;\n\n    const host = cert.host || \"this certificate\";\n    const port = cert.port != null ? `:${cert.port}` : \"\";\n\n    const confirmed = await showConfirmDelete({\n      id: \"confirm-remove-certificate\",\n      title: \"Delete Certificate\",\n      description: (\n        <>\n          Permanently delete certificate for{\" \"}\n          <InlineCode>\n            {host}\n            {port}\n          </InlineCode>\n          ?\n        </>\n      ),\n    });\n\n    if (!confirmed) return;\n\n    const newCertificates = certificates.filter((_, i) => i !== index);\n\n    await updateCertificates(newCertificates);\n  };\n\n  return (\n    <VStack space={3}>\n      <div className=\"mb-3\">\n        <HStack justifyContent=\"between\" alignItems=\"start\">\n          <div>\n            <Heading>Client Certificates</Heading>\n            <p className=\"text-text-subtle\">\n              Add and manage TLS certificates on a per domain basis\n            </p>\n          </div>\n          <Button variant=\"border\" size=\"sm\" color=\"secondary\" onClick={handleAdd}>\n            Add Certificate\n          </Button>\n        </HStack>\n      </div>\n\n      {certificates.length > 0 && (\n        <VStack space={3}>\n          {certificates.map((cert, index) => (\n            <CertificateEditor\n              // oxlint-disable-next-line react/no-array-index-key\n              key={index}\n              certificate={cert}\n              index={index}\n              onUpdate={handleUpdate}\n              onRemove={handleRemove}\n            />\n          ))}\n        </VStack>\n      )}\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/Settings/SettingsGeneral.tsx",
    "content": "import { revealItemInDir } from \"@tauri-apps/plugin-opener\";\nimport { patchModel, settingsAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { activeWorkspaceAtom } from \"../../hooks/useActiveWorkspace\";\nimport { useCheckForUpdates } from \"../../hooks/useCheckForUpdates\";\nimport { appInfo } from \"../../lib/appInfo\";\nimport { revealInFinderText } from \"../../lib/reveal\";\nimport { CargoFeature } from \"../CargoFeature\";\nimport { Checkbox } from \"../core/Checkbox\";\nimport { Heading } from \"../core/Heading\";\nimport { IconButton } from \"../core/IconButton\";\nimport { KeyValueRow, KeyValueRows } from \"../core/KeyValueRow\";\nimport { PlainInput } from \"../core/PlainInput\";\nimport { Select } from \"../core/Select\";\nimport { Separator } from \"../core/Separator\";\nimport { VStack } from \"../core/Stacks\";\n\nexport function SettingsGeneral() {\n  const workspace = useAtomValue(activeWorkspaceAtom);\n  const settings = useAtomValue(settingsAtom);\n  const checkForUpdates = useCheckForUpdates();\n\n  if (settings == null || workspace == null) {\n    return null;\n  }\n\n  return (\n    <VStack space={1.5} className=\"mb-4\">\n      <div className=\"mb-4\">\n        <Heading>General</Heading>\n        <p className=\"text-text-subtle\">Configure general settings for update behavior and more.</p>\n      </div>\n      <CargoFeature feature=\"updater\">\n        <div className=\"grid grid-cols-[minmax(0,1fr)_auto] gap-1\">\n          <Select\n            name=\"updateChannel\"\n            label=\"Update Channel\"\n            labelPosition=\"left\"\n            labelClassName=\"w-[14rem]\"\n            size=\"sm\"\n            value={settings.updateChannel}\n            onChange={(updateChannel) => patchModel(settings, { updateChannel })}\n            options={[\n              { label: \"Stable\", value: \"stable\" },\n              { label: \"Beta (more frequent)\", value: \"beta\" },\n            ]}\n          />\n          <IconButton\n            variant=\"border\"\n            size=\"sm\"\n            title=\"Check for updates\"\n            icon=\"refresh\"\n            spin={checkForUpdates.isPending}\n            onClick={() => checkForUpdates.mutateAsync()}\n          />\n        </div>\n\n        <Select\n          name=\"autoupdate\"\n          value={settings.autoupdate ? \"auto\" : \"manual\"}\n          label=\"Update Behavior\"\n          labelPosition=\"left\"\n          size=\"sm\"\n          labelClassName=\"w-[14rem]\"\n          onChange={(v) => patchModel(settings, { autoupdate: v === \"auto\" })}\n          options={[\n            { label: \"Automatic\", value: \"auto\" },\n            { label: \"Manual\", value: \"manual\" },\n          ]}\n        />\n        <Checkbox\n          className=\"pl-2 mt-1 ml-[14rem]\"\n          checked={settings.autoDownloadUpdates}\n          disabled={!settings.autoupdate}\n          help=\"Automatically download Yaak updates (!50MB) in the background, so they will be immediately ready to install.\"\n          title=\"Automatically download updates\"\n          onChange={(autoDownloadUpdates) => patchModel(settings, { autoDownloadUpdates })}\n        />\n\n        <Checkbox\n          className=\"pl-2 mt-1 ml-[14rem]\"\n          checked={settings.checkNotifications}\n          title=\"Check for notifications\"\n          help=\"Periodically ping Yaak servers to check for relevant notifications.\"\n          onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}\n        />\n        <Checkbox\n          disabled\n          className=\"pl-2 mt-1 ml-[14rem]\"\n          checked={false}\n          title=\"Send anonymous usage statistics\"\n          help=\"Yaak is local-first and does not collect analytics or usage data 🔐\"\n          onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}\n        />\n      </CargoFeature>\n\n      <Separator className=\"my-4\" />\n\n      <Heading level={2}>\n        Workspace{\" \"}\n        <div className=\"inline-block ml-1 bg-surface-highlight px-2 py-0.5 rounded text text-shrink\">\n          {workspace.name}\n        </div>\n      </Heading>\n      <VStack className=\"mt-1 w-full\" space={3}>\n        <PlainInput\n          required\n          size=\"sm\"\n          name=\"requestTimeout\"\n          label=\"Request Timeout (ms)\"\n          labelClassName=\"w-[14rem]\"\n          placeholder=\"0\"\n          labelPosition=\"left\"\n          defaultValue={`${workspace.settingRequestTimeout}`}\n          validate={(value) => Number.parseInt(value, 10) >= 0}\n          onChange={(v) =>\n            patchModel(workspace, { settingRequestTimeout: Number.parseInt(v, 10) || 0 })\n          }\n          type=\"number\"\n        />\n\n        <Checkbox\n          checked={workspace.settingValidateCertificates}\n          help=\"When disabled, skip validation of server certificates, useful when interacting with self-signed certs.\"\n          title=\"Validate TLS certificates\"\n          onChange={(settingValidateCertificates) =>\n            patchModel(workspace, { settingValidateCertificates })\n          }\n        />\n\n        <Checkbox\n          checked={workspace.settingFollowRedirects}\n          title=\"Follow redirects\"\n          onChange={(settingFollowRedirects) =>\n            patchModel(workspace, {\n              settingFollowRedirects,\n            })\n          }\n        />\n      </VStack>\n\n      <Separator className=\"my-4\" />\n\n      <Heading level={2}>App Info</Heading>\n      <KeyValueRows>\n        <KeyValueRow label=\"Version\">{appInfo.version}</KeyValueRow>\n        <KeyValueRow\n          label=\"Data Directory\"\n          rightSlot={\n            <IconButton\n              title={revealInFinderText}\n              icon=\"folder_open\"\n              size=\"2xs\"\n              onClick={() => revealItemInDir(appInfo.appDataDir)}\n            />\n          }\n        >\n          {appInfo.appDataDir}\n        </KeyValueRow>\n        <KeyValueRow\n          label=\"Logs Directory\"\n          rightSlot={\n            <IconButton\n              title={revealInFinderText}\n              icon=\"folder_open\"\n              size=\"2xs\"\n              onClick={() => revealItemInDir(appInfo.appLogDir)}\n            />\n          }\n        >\n          {appInfo.appLogDir}\n        </KeyValueRow>\n      </KeyValueRows>\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/Settings/SettingsHotkeys.tsx",
    "content": "import { patchModel, settingsAtom } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { fuzzyMatch } from \"fuzzbunny\";\nimport { useAtomValue } from \"jotai\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport {\n  defaultHotkeys,\n  formatHotkeyString,\n  getHotkeyScope,\n  type HotkeyAction,\n  hotkeyActions,\n  hotkeysAtom,\n  useHotkeyLabel,\n} from \"../../hooks/useHotKey\";\nimport { capitalize } from \"../../lib/capitalize\";\nimport { showDialog } from \"../../lib/dialog\";\nimport { Button } from \"../core/Button\";\nimport { Dropdown, type DropdownItem } from \"../core/Dropdown\";\nimport { Heading } from \"../core/Heading\";\nimport { HotkeyRaw } from \"../core/Hotkey\";\nimport { Icon } from \"../core/Icon\";\nimport { IconButton } from \"../core/IconButton\";\nimport { PlainInput } from \"../core/PlainInput\";\nimport { HStack, VStack } from \"../core/Stacks\";\nimport { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from \"../core/Table\";\n\nconst HOLD_KEYS = [\"Shift\", \"Control\", \"Alt\", \"Meta\"];\nconst LAYOUT_INSENSITIVE_KEYS = [\n  \"Equal\",\n  \"Minus\",\n  \"BracketLeft\",\n  \"BracketRight\",\n  \"Backquote\",\n  \"Space\",\n];\n\n/** Convert a KeyboardEvent to a hotkey string like \"Meta+Shift+k\" or \"Control+Shift+k\" */\nfunction eventToHotkeyString(e: KeyboardEvent): string | null {\n  // Don't capture modifier-only key presses\n  if (HOLD_KEYS.includes(e.key)) {\n    return null;\n  }\n\n  const parts: string[] = [];\n\n  // Add modifiers in consistent order (Meta, Control, Alt, Shift)\n  if (e.metaKey) {\n    parts.push(\"Meta\");\n  }\n  if (e.ctrlKey) {\n    parts.push(\"Control\");\n  }\n  if (e.altKey) {\n    parts.push(\"Alt\");\n  }\n  if (e.shiftKey) {\n    parts.push(\"Shift\");\n  }\n\n  // Get the main key - use the same logic as useHotKey.ts\n  const key = LAYOUT_INSENSITIVE_KEYS.includes(e.code) ? e.code : e.key;\n  parts.push(key);\n\n  return parts.join(\"+\");\n}\n\nexport function SettingsHotkeys() {\n  const settings = useAtomValue(settingsAtom);\n  const hotkeys = useAtomValue(hotkeysAtom);\n  const [filter, setFilter] = useState(\"\");\n\n  const filteredActions = useMemo(() => {\n    if (!filter.trim()) {\n      return hotkeyActions;\n    }\n    return hotkeyActions.filter((action) => {\n      const scope = getHotkeyScope(action).replace(/_/g, \" \");\n      const label = action.replace(/[_.]/g, \" \");\n      const searchText = `${scope} ${label}`;\n      return fuzzyMatch(searchText, filter) != null;\n    });\n  }, [filter]);\n\n  if (settings == null) {\n    return null;\n  }\n\n  return (\n    <VStack space={3} className=\"mb-4\">\n      <div className=\"mb-3\">\n        <Heading>Keyboard Shortcuts</Heading>\n        <p className=\"text-text-subtle\">\n          Click the menu button to add, remove, or reset keyboard shortcuts.\n        </p>\n      </div>\n      <PlainInput\n        label=\"Filter\"\n        placeholder=\"Filter shortcuts...\"\n        defaultValue={filter}\n        onChange={setFilter}\n        hideLabel\n        containerClassName=\"max-w-xs\"\n      />\n      <Table>\n        <TableHead>\n          <TableRow>\n            <TableHeaderCell>Scope</TableHeaderCell>\n            <TableHeaderCell>Action</TableHeaderCell>\n            <TableHeaderCell>Shortcut</TableHeaderCell>\n            <TableHeaderCell></TableHeaderCell>\n          </TableRow>\n        </TableHead>\n        {/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}\n        <TableBody key={filter}>\n          {filteredActions.map((action) => (\n            <HotkeyRow\n              key={action}\n              action={action}\n              currentKeys={hotkeys[action]}\n              defaultKeys={defaultHotkeys[action]}\n              onSave={async (keys) => {\n                const newHotkeys = { ...settings.hotkeys };\n                if (arraysEqual(keys, defaultHotkeys[action])) {\n                  // Remove from settings if it matches default (use default)\n                  delete newHotkeys[action];\n                } else {\n                  // Store the keys (including empty array to disable)\n                  newHotkeys[action] = keys;\n                }\n                await patchModel(settings, { hotkeys: newHotkeys });\n              }}\n              onReset={async () => {\n                const newHotkeys = { ...settings.hotkeys };\n                delete newHotkeys[action];\n                await patchModel(settings, { hotkeys: newHotkeys });\n              }}\n            />\n          ))}\n        </TableBody>\n      </Table>\n    </VStack>\n  );\n}\n\ninterface HotkeyRowProps {\n  action: HotkeyAction;\n  currentKeys: string[];\n  defaultKeys: string[];\n  onSave: (keys: string[]) => Promise<void>;\n  onReset: () => Promise<void>;\n}\n\nfunction HotkeyRow({ action, currentKeys, defaultKeys, onSave, onReset }: HotkeyRowProps) {\n  const label = useHotkeyLabel(action);\n  const scope = capitalize(getHotkeyScope(action).replace(/_/g, \" \"));\n  const isCustomized = !arraysEqual(currentKeys, defaultKeys);\n  const isDisabled = currentKeys.length === 0;\n\n  const handleStartRecording = useCallback(() => {\n    showDialog({\n      id: `record-hotkey-${action}`,\n      title: label,\n      size: \"sm\",\n      render: ({ hide }) => (\n        <RecordHotkeyDialog\n          label={label}\n          onSave={async (key) => {\n            await onSave([...currentKeys, key]);\n            hide();\n          }}\n          onCancel={hide}\n        />\n      ),\n    });\n  }, [action, label, currentKeys, onSave]);\n\n  const handleRemove = useCallback(\n    async (keyToRemove: string) => {\n      const newKeys = currentKeys.filter((k) => k !== keyToRemove);\n      await onSave(newKeys);\n    },\n    [currentKeys, onSave],\n  );\n\n  const handleClearAll = useCallback(async () => {\n    await onSave([]);\n  }, [onSave]);\n\n  // Build dropdown items dynamically\n  const dropdownItems: DropdownItem[] = [\n    {\n      label: \"Add Keyboard Shortcut\",\n      leftSlot: <Icon icon=\"plus\" />,\n      onSelect: handleStartRecording,\n    },\n  ];\n\n  // Add remove options for each existing shortcut\n  if (!isDisabled) {\n    currentKeys.forEach((key) => {\n      dropdownItems.push({\n        label: (\n          <HStack space={1.5}>\n            <span>Remove</span>\n            <HotkeyRaw labelParts={formatHotkeyString(key)} variant=\"with-bg\" className=\"text-xs\" />\n          </HStack>\n        ),\n        leftSlot: <Icon icon=\"trash\" />,\n        onSelect: () => handleRemove(key),\n      });\n    });\n\n    if (currentKeys.length > 1) {\n      dropdownItems.push(\n        {\n          type: \"separator\",\n        },\n        {\n          label: \"Remove All Shortcuts\",\n          leftSlot: <Icon icon=\"trash\" />,\n          onSelect: handleClearAll,\n        },\n      );\n    }\n  }\n\n  if (isCustomized) {\n    dropdownItems.push({\n      type: \"separator\",\n    });\n    dropdownItems.push({\n      label: \"Reset to Default\",\n      leftSlot: <Icon icon=\"refresh\" />,\n      onSelect: onReset,\n    });\n  }\n\n  return (\n    <TableRow>\n      <TableCell>\n        <span className=\"text-sm text-text-subtlest\">{scope}</span>\n      </TableCell>\n      <TableCell>\n        <span className=\"text-sm\">{label}</span>\n      </TableCell>\n      <TableCell>\n        <HStack space={1.5} className=\"py-1\">\n          {isDisabled ? (\n            <span className=\"text-text-subtlest\">Disabled</span>\n          ) : (\n            currentKeys.map((k) => (\n              <HotkeyRaw key={k} labelParts={formatHotkeyString(k)} variant=\"with-bg\" />\n            ))\n          )}\n        </HStack>\n      </TableCell>\n      <TableCell align=\"right\">\n        <Dropdown items={dropdownItems}>\n          <IconButton\n            icon=\"ellipsis_vertical\"\n            size=\"sm\"\n            title=\"Hotkey actions\"\n            className=\"ml-auto text-text-subtlest\"\n          />\n        </Dropdown>\n      </TableCell>\n    </TableRow>\n  );\n}\n\nfunction arraysEqual(a: string[], b: string[]): boolean {\n  if (a.length !== b.length) return false;\n  const sortedA = [...a].sort();\n  const sortedB = [...b].sort();\n  return sortedA.every((v, i) => v === sortedB[i]);\n}\n\ninterface RecordHotkeyDialogProps {\n  label: string;\n  onSave: (key: string) => void;\n  onCancel: () => void;\n}\n\nfunction RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps) {\n  const [recordedKey, setRecordedKey] = useState<string | null>(null);\n  const [isFocused, setIsFocused] = useState(false);\n\n  useEffect(() => {\n    if (!isFocused) return;\n\n    const handleKeyDown = (e: KeyboardEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n\n      if (e.key === \"Escape\") {\n        onCancel();\n        return;\n      }\n\n      const hotkeyString = eventToHotkeyString(e);\n      if (hotkeyString) {\n        setRecordedKey(hotkeyString);\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown, { capture: true });\n    return () => {\n      window.removeEventListener(\"keydown\", handleKeyDown, { capture: true });\n    };\n  }, [isFocused, onCancel]);\n\n  const handleSave = useCallback(() => {\n    if (recordedKey) {\n      onSave(recordedKey);\n    }\n  }, [recordedKey, onSave]);\n\n  return (\n    <VStack space={4}>\n      <div>\n        <p className=\"text-text-subtle mb-2\">\n          Record a key combination for <span className=\"font-semibold\">{label}</span>\n        </p>\n        <button\n          type=\"button\"\n          data-disable-hotkey\n          aria-label=\"Keyboard shortcut input\"\n          onFocus={() => setIsFocused(true)}\n          onBlur={() => setIsFocused(false)}\n          onClick={(e) => {\n            e.preventDefault();\n            e.currentTarget.focus();\n          }}\n          className={classNames(\n            \"flex items-center justify-center\",\n            \"px-4 py-2 rounded-lg bg-surface-highlight border outline-none cursor-default w-full\",\n            \"border-border-subtle focus:border-border-focus\",\n          )}\n        >\n          {recordedKey ? (\n            <HotkeyRaw labelParts={formatHotkeyString(recordedKey)} />\n          ) : (\n            <span className=\"text-text-subtlest\">Press keys...</span>\n          )}\n        </button>\n      </div>\n      <HStack space={2} justifyContent=\"end\">\n        <Button color=\"secondary\" onClick={onCancel}>\n          Cancel\n        </Button>\n        <Button color=\"primary\" onClick={handleSave} disabled={!recordedKey}>\n          Save\n        </Button>\n      </HStack>\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/Settings/SettingsInterface.tsx",
    "content": "import { type } from \"@tauri-apps/plugin-os\";\nimport { useFonts } from \"@yaakapp-internal/fonts\";\nimport { useLicense } from \"@yaakapp-internal/license\";\nimport type { EditorKeymap, Settings } from \"@yaakapp-internal/models\";\nimport { patchModel, settingsAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useState } from \"react\";\n\nimport { activeWorkspaceAtom } from \"../../hooks/useActiveWorkspace\";\nimport { clamp } from \"../../lib/clamp\";\nimport { showConfirm } from \"../../lib/confirm\";\nimport { invokeCmd } from \"../../lib/tauri\";\nimport { CargoFeature } from \"../CargoFeature\";\nimport { Button } from \"../core/Button\";\nimport { Checkbox } from \"../core/Checkbox\";\nimport { Heading } from \"../core/Heading\";\nimport { Icon } from \"../core/Icon\";\nimport { Link } from \"../core/Link\";\nimport { Select } from \"../core/Select\";\nimport { HStack, VStack } from \"../core/Stacks\";\n\nconst NULL_FONT_VALUE = \"__NULL_FONT__\";\n\nconst fontSizeOptions = [\n  8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,\n].map((n) => ({ label: `${n}`, value: `${n}` }));\n\nconst keymaps: { value: EditorKeymap; label: string }[] = [\n  { value: \"default\", label: \"Default\" },\n  { value: \"vim\", label: \"Vim\" },\n  { value: \"vscode\", label: \"VSCode\" },\n  { value: \"emacs\", label: \"Emacs\" },\n];\n\nexport function SettingsInterface() {\n  const workspace = useAtomValue(activeWorkspaceAtom);\n  const settings = useAtomValue(settingsAtom);\n  const fonts = useFonts();\n\n  if (settings == null || workspace == null) {\n    return null;\n  }\n\n  return (\n    <VStack space={3} className=\"mb-4\">\n      <div className=\"mb-3\">\n        <Heading>Interface</Heading>\n        <p className=\"text-text-subtle\">Tweak settings related to the user interface.</p>\n      </div>\n      <Select\n        name=\"switchWorkspaceBehavior\"\n        label=\"Open workspace behavior\"\n        size=\"sm\"\n        help=\"When opening a workspace, should it open in the current window or a new window?\"\n        value={\n          settings.openWorkspaceNewWindow === true\n            ? \"new\"\n            : settings.openWorkspaceNewWindow === false\n              ? \"current\"\n              : \"ask\"\n        }\n        onChange={async (v) => {\n          if (v === \"current\") await patchModel(settings, { openWorkspaceNewWindow: false });\n          else if (v === \"new\") await patchModel(settings, { openWorkspaceNewWindow: true });\n          else await patchModel(settings, { openWorkspaceNewWindow: null });\n        }}\n        options={[\n          { label: \"Always ask\", value: \"ask\" },\n          { label: \"Open in current window\", value: \"current\" },\n          { label: \"Open in new window\", value: \"new\" },\n        ]}\n      />\n      <HStack space={2} alignItems=\"end\">\n        {fonts.data && (\n          <Select\n            size=\"sm\"\n            name=\"uiFont\"\n            label=\"Interface font\"\n            value={settings.interfaceFont ?? NULL_FONT_VALUE}\n            options={[\n              { label: \"System default\", value: NULL_FONT_VALUE },\n              ...(fonts.data.uiFonts.map((f) => ({\n                label: f,\n                value: f,\n              })) ?? []),\n              // Some people like monospace fonts for the UI\n              ...(fonts.data.editorFonts.map((f) => ({\n                label: f,\n                value: f,\n              })) ?? []),\n            ]}\n            onChange={async (v) => {\n              const interfaceFont = v === NULL_FONT_VALUE ? null : v;\n              await patchModel(settings, { interfaceFont });\n            }}\n          />\n        )}\n        <Select\n          hideLabel\n          size=\"sm\"\n          name=\"interfaceFontSize\"\n          label=\"Interface Font Size\"\n          defaultValue=\"14\"\n          value={`${settings.interfaceFontSize}`}\n          options={fontSizeOptions}\n          onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}\n        />\n      </HStack>\n      <HStack space={2} alignItems=\"end\">\n        {fonts.data && (\n          <Select\n            size=\"sm\"\n            name=\"editorFont\"\n            label=\"Editor font\"\n            value={settings.editorFont ?? NULL_FONT_VALUE}\n            options={[\n              { label: \"System default\", value: NULL_FONT_VALUE },\n              ...(fonts.data.editorFonts.map((f) => ({\n                label: f,\n                value: f,\n              })) ?? []),\n            ]}\n            onChange={async (v) => {\n              const editorFont = v === NULL_FONT_VALUE ? null : v;\n              await patchModel(settings, { editorFont });\n            }}\n          />\n        )}\n        <Select\n          hideLabel\n          size=\"sm\"\n          name=\"editorFontSize\"\n          label=\"Editor Font Size\"\n          defaultValue=\"12\"\n          value={`${settings.editorFontSize}`}\n          options={fontSizeOptions}\n          onChange={(v) =>\n            patchModel(settings, { editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30) })\n          }\n        />\n      </HStack>\n      <Select\n        leftSlot={<Icon icon=\"keyboard\" color=\"secondary\" />}\n        size=\"sm\"\n        name=\"editorKeymap\"\n        label=\"Editor keymap\"\n        value={`${settings.editorKeymap}`}\n        options={keymaps}\n        onChange={(v) => patchModel(settings, { editorKeymap: v })}\n      />\n      <Checkbox\n        checked={settings.editorSoftWrap}\n        title=\"Wrap editor lines\"\n        onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}\n      />\n      <Checkbox\n        checked={settings.coloredMethods}\n        title=\"Colorize request methods\"\n        onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}\n      />\n      <CargoFeature feature=\"license\">\n        <LicenseSettings settings={settings} />\n      </CargoFeature>\n\n      <NativeTitlebarSetting settings={settings} />\n\n      {type() !== \"macos\" && (\n        <Checkbox\n          checked={settings.hideWindowControls}\n          title=\"Hide window controls\"\n          help=\"Hide the close/maximize/minimize controls on Windows or Linux\"\n          onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })}\n        />\n      )}\n    </VStack>\n  );\n}\n\nfunction NativeTitlebarSetting({ settings }: { settings: Settings }) {\n  const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);\n  return (\n    <div className=\"flex gap-1 overflow-hidden h-2xs\">\n      <Checkbox\n        checked={nativeTitlebar}\n        title=\"Native title bar\"\n        help=\"Use the operating system's standard title bar and window controls\"\n        onChange={setNativeTitlebar}\n      />\n      {settings.useNativeTitlebar !== nativeTitlebar && (\n        <Button\n          color=\"primary\"\n          size=\"2xs\"\n          onClick={async () => {\n            await patchModel(settings, { useNativeTitlebar: nativeTitlebar });\n            await invokeCmd(\"cmd_restart\");\n          }}\n        >\n          Apply and Restart\n        </Button>\n      )}\n    </div>\n  );\n}\n\nfunction LicenseSettings({ settings }: { settings: Settings }) {\n  const license = useLicense();\n  if (license.check.data?.status !== \"personal_use\") {\n    return null;\n  }\n\n  return (\n    <Checkbox\n      checked={settings.hideLicenseBadge}\n      title=\"Hide personal use badge\"\n      onChange={async (hideLicenseBadge) => {\n        if (hideLicenseBadge) {\n          const confirmed = await showConfirm({\n            id: \"hide-license-badge\",\n            title: \"Confirm Personal Use\",\n            confirmText: \"Confirm\",\n            description: (\n              <VStack space={3}>\n                <p>Hey there 👋🏼</p>\n                <p>\n                  Yaak is free for personal projects and learning.{\" \"}\n                  <strong>If you’re using Yaak at work, a license is required.</strong>\n                </p>\n                <p>\n                  Licenses help keep Yaak independent and sustainable.{\" \"}\n                  <Link href=\"https://yaak.app/pricing?s=badge\">Purchase a License →</Link>\n                </p>\n              </VStack>\n            ),\n            requireTyping: \"Personal Use\",\n            color: \"info\",\n          });\n          if (!confirmed) {\n            return; // Cancel\n          }\n        }\n        await patchModel(settings, { hideLicenseBadge });\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/Settings/SettingsLicense.tsx",
    "content": "import { openUrl } from \"@tauri-apps/plugin-opener\";\nimport { useLicense } from \"@yaakapp-internal/license\";\nimport { differenceInDays } from \"date-fns\";\nimport { formatDate } from \"date-fns/format\";\nimport { useState } from \"react\";\nimport { useToggle } from \"../../hooks/useToggle\";\nimport { pluralizeCount } from \"../../lib/pluralize\";\nimport { CargoFeature } from \"../CargoFeature\";\nimport { Banner } from \"../core/Banner\";\nimport { Button } from \"../core/Button\";\nimport { Icon } from \"../core/Icon\";\nimport { Link } from \"../core/Link\";\nimport { PlainInput } from \"../core/PlainInput\";\nimport { Separator } from \"../core/Separator\";\nimport { HStack, VStack } from \"../core/Stacks\";\n\nexport function SettingsLicense() {\n  return (\n    <CargoFeature feature=\"license\">\n      <SettingsLicenseCmp />\n    </CargoFeature>\n  );\n}\n\nfunction SettingsLicenseCmp() {\n  const { check, activate, deactivate } = useLicense();\n  const [key, setKey] = useState<string>(\"\");\n  const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);\n\n  if (check.isPending) {\n    return null;\n  }\n\n  const renderBanner = () => {\n    if (!check.data) return null;\n\n    switch (check.data.status) {\n      case \"active\":\n        return <Banner color=\"success\">Your license is active 🥳</Banner>;\n\n      case \"trialing\":\n        return (\n          <Banner color=\"info\" className=\"max-w-lg\">\n            <p className=\"w-full\">\n              <strong>\n                {pluralizeCount(\"day\", differenceInDays(check.data.data.end, new Date()))}\n              </strong>{\" \"}\n              left to evaluate Yaak for commercial use.\n              <br />\n              <span className=\"opacity-50\">Personal use is always free, forever.</span>\n              <Separator className=\"my-2\" />\n              <div className=\"flex flex-wrap items-center gap-x-2 text-sm text-notice\">\n                <Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>\n                  Learn More\n                </Link>\n              </div>\n            </p>\n          </Banner>\n        );\n\n      case \"personal_use\":\n        return (\n          <Banner color=\"notice\" className=\"max-w-lg\">\n            <p className=\"w-full\">\n              Your commercial-use trial has ended.\n              <br />\n              <span className=\"opacity-50\">\n                You may continue using Yaak for personal use only.\n                <br />A license is required for commercial use.\n              </span>\n              <Separator className=\"my-2\" />\n              <div className=\"flex flex-wrap items-center gap-x-2 text-sm text-notice\">\n                <Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>\n                  Learn More\n                </Link>\n              </div>\n            </p>\n          </Banner>\n        );\n\n      case \"inactive\":\n        return (\n          <Banner color=\"danger\">\n            Your license is invalid. Please <Link href=\"https://yaak.app/dashboard\">Sign In</Link>{\" \"}\n            for more details\n          </Banner>\n        );\n\n      case \"expired\":\n        return (\n          <Banner color=\"notice\">\n            Your license expired{\" \"}\n            <strong>{formatDate(check.data.data.periodEnd, \"MMMM dd, yyyy\")}</strong>. Please{\" \"}\n            <Link href=\"https://yaak.app/dashboard\">Resubscribe</Link> to continue receiving\n            updates.\n            {check.data.data.changesUrl && (\n              <>\n                <br />\n                <Link href={check.data.data.changesUrl}>What's new in latest builds</Link>\n              </>\n            )}\n          </Banner>\n        );\n\n      case \"past_due\":\n        return (\n          <Banner color=\"danger\">\n            <strong>Your payment method needs attention.</strong>\n            <br />\n            To re-activate your license, please{\" \"}\n            <Link href={check.data.data.billingUrl}>update your billing info</Link>.\n          </Banner>\n        );\n\n      case \"error\":\n        return (\n          <Banner color=\"danger\">\n            License check failed: {check.data.data.message} (Code: {check.data.data.code})\n          </Banner>\n        );\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col gap-6 max-w-xl\">\n      {renderBanner()}\n\n      {check.error && <Banner color=\"danger\">{check.error}</Banner>}\n      {activate.error && <Banner color=\"danger\">{activate.error}</Banner>}\n\n      {check.data?.status === \"active\" ? (\n        <HStack space={2}>\n          <Button variant=\"border\" color=\"secondary\" size=\"sm\" onClick={() => deactivate.mutate()}>\n            Deactivate License\n          </Button>\n          <Button\n            color=\"secondary\"\n            size=\"sm\"\n            onClick={() => openUrl(\"https://yaak.app/dashboard?s=support&ref=app.yaak.desktop\")}\n            rightSlot={<Icon icon=\"external_link\" />}\n          >\n            Direct Support\n          </Button>\n        </HStack>\n      ) : (\n        <HStack space={2}>\n          <Button variant=\"border\" color=\"secondary\" size=\"sm\" onClick={toggleActivateFormVisible}>\n            Activate License\n          </Button>\n          <Button\n            size=\"sm\"\n            color=\"primary\"\n            rightSlot={<Icon icon=\"external_link\" />}\n            onClick={() =>\n              openUrl(\n                `https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? \"\"}`,\n              )\n            }\n          >\n            Purchase License\n          </Button>\n        </HStack>\n      )}\n\n      {activateFormVisible && (\n        <VStack\n          as=\"form\"\n          space={3}\n          className=\"max-w-sm\"\n          onSubmit={async (e) => {\n            e.preventDefault();\n            await activate.mutateAsync({ licenseKey: key });\n            toggleActivateFormVisible();\n          }}\n        >\n          <PlainInput\n            autoFocus\n            label=\"License Key\"\n            name=\"key\"\n            onChange={setKey}\n            placeholder=\"YK1-XXXXX-XXXXX-XXXXX-XXXXX\"\n          />\n          <Button type=\"submit\" color=\"primary\" size=\"sm\" isLoading={activate.isPending}>\n            Submit\n          </Button>\n        </VStack>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/Settings/SettingsPlugins.tsx",
    "content": "import { useMutation, useQuery } from \"@tanstack/react-query\";\nimport { openUrl } from \"@tauri-apps/plugin-opener\";\nimport type { Plugin } from \"@yaakapp-internal/models\";\nimport { patchModel, pluginsAtom } from \"@yaakapp-internal/models\";\nimport type { PluginVersion } from \"@yaakapp-internal/plugins\";\nimport {\n  checkPluginUpdates,\n  installPlugin,\n  searchPlugins,\n  uninstallPlugin,\n} from \"@yaakapp-internal/plugins\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport { useState } from \"react\";\nimport { useDebouncedValue } from \"../../hooks/useDebouncedValue\";\nimport { useInstallPlugin } from \"../../hooks/useInstallPlugin\";\nimport { usePluginInfo } from \"../../hooks/usePluginInfo\";\nimport { usePluginsKey, useRefreshPlugins } from \"../../hooks/usePlugins\";\nimport { showConfirmDelete } from \"../../lib/confirm\";\nimport { minPromiseMillis } from \"../../lib/minPromiseMillis\";\nimport { Button } from \"../core/Button\";\nimport { Checkbox } from \"../core/Checkbox\";\nimport { CountBadge } from \"../core/CountBadge\";\nimport { Icon } from \"../core/Icon\";\nimport { IconButton } from \"../core/IconButton\";\nimport { InlineCode } from \"../core/InlineCode\";\nimport { Link } from \"../core/Link\";\nimport { LoadingIcon } from \"../core/LoadingIcon\";\nimport { PlainInput } from \"../core/PlainInput\";\nimport { HStack } from \"../core/Stacks\";\nimport { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from \"../core/Table\";\nimport { TabContent, Tabs } from \"../core/Tabs/Tabs\";\nimport { EmptyStateText } from \"../EmptyStateText\";\nimport { SelectFile } from \"../SelectFile\";\n\ninterface SettingsPluginsProps {\n  defaultSubtab?: string;\n}\n\nexport function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {\n  const [directory, setDirectory] = useState<string | null>(null);\n  const plugins = useAtomValue(pluginsAtom);\n  const bundledPlugins = plugins.filter((p) => p.source === \"bundled\");\n  const installedPlugins = plugins.filter((p) => p.source !== \"bundled\");\n  const createPlugin = useInstallPlugin();\n  const refreshPlugins = useRefreshPlugins();\n  return (\n    <div className=\"h-full\">\n      <Tabs\n        defaultValue={defaultSubtab}\n        label=\"Plugins\"\n        addBorders\n        tabListClassName=\"px-6 pt-2\"\n        tabs={[\n          { label: \"Discover\", value: \"search\" },\n          {\n            label: \"Installed\",\n            value: \"installed\",\n            rightSlot: <CountBadge count={installedPlugins.length} />,\n          },\n          {\n            label: \"Bundled\",\n            value: \"bundled\",\n            rightSlot: <CountBadge count={bundledPlugins.length} />,\n          },\n        ]}\n      >\n        <TabContent value=\"search\" className=\"px-6\">\n          <PluginSearch />\n        </TabContent>\n        <TabContent value=\"installed\" className=\"pb-0\">\n          <div className=\"h-full grid grid-rows-[minmax(0,1fr)_auto]\">\n            <InstalledPlugins plugins={installedPlugins} className=\"px-6\" />\n            <footer className=\"grid grid-cols-[minmax(0,1fr)_auto] py-2 px-4 border-t bg-surface-highlight border-border-subtle min-w-0\">\n              <SelectFile\n                size=\"xs\"\n                noun=\"Plugin\"\n                directory\n                onChange={({ filePath }) => setDirectory(filePath)}\n                filePath={directory}\n              />\n              <HStack>\n                {directory && (\n                  <Button\n                    size=\"xs\"\n                    color=\"primary\"\n                    className=\"ml-auto\"\n                    onClick={() => {\n                      if (directory == null) return;\n                      createPlugin.mutate(directory);\n                      setDirectory(null);\n                    }}\n                  >\n                    Add Plugin\n                  </Button>\n                )}\n                <IconButton\n                  size=\"sm\"\n                  icon=\"refresh\"\n                  title=\"Reload plugins\"\n                  spin={refreshPlugins.isPending}\n                  onClick={() => refreshPlugins.mutate()}\n                />\n                <IconButton\n                  size=\"sm\"\n                  icon=\"help\"\n                  title=\"View documentation\"\n                  onClick={() =>\n                    openUrl(\"https://yaak.app/docs/plugin-development/plugins-quick-start\")\n                  }\n                />\n              </HStack>\n            </footer>\n          </div>\n        </TabContent>\n        <TabContent value=\"bundled\" className=\"pb-0 px-6\">\n          <BundledPlugins plugins={bundledPlugins} />\n        </TabContent>\n      </Tabs>\n    </div>\n  );\n}\n\nfunction PluginTableRowForInstalledPlugin({ plugin }: { plugin: Plugin }) {\n  const info = usePluginInfo(plugin.id).data;\n  if (info == null) {\n    return null;\n  }\n\n  return (\n    <PluginTableRow\n      plugin={plugin}\n      version={info.version}\n      name={info.name}\n      displayName={info.displayName}\n      url={plugin.url}\n      showCheckbox={true}\n      showUninstall={true}\n    />\n  );\n}\n\nfunction PluginTableRowForBundledPlugin({ plugin }: { plugin: Plugin }) {\n  const info = usePluginInfo(plugin.id).data;\n  if (info == null) {\n    return null;\n  }\n\n  return (\n    <PluginTableRow\n      plugin={plugin}\n      version={info.version}\n      name={info.name}\n      displayName={info.displayName}\n      url={plugin.url}\n      showCheckbox={true}\n      showUninstall={false}\n    />\n  );\n}\n\nfunction PluginTableRowForRemotePluginVersion({ pluginVersion }: { pluginVersion: PluginVersion }) {\n  const plugin = useAtomValue(pluginsAtom).find((p) => p.id === pluginVersion.id);\n  const pluginInfo = usePluginInfo(plugin?.id ?? null).data;\n\n  return (\n    <PluginTableRow\n      plugin={plugin ?? null}\n      version={pluginInfo?.version ?? pluginVersion.version}\n      name={pluginVersion.name}\n      displayName={pluginVersion.displayName}\n      url={pluginVersion.url}\n      showCheckbox={false}\n    />\n  );\n}\n\nfunction PluginTableRow({\n  plugin,\n  name,\n  version,\n  displayName,\n  url,\n  showCheckbox = true,\n  showUninstall = true,\n}: {\n  plugin: Plugin | null;\n  name: string;\n  version: string;\n  displayName: string;\n  url: string | null;\n  showCheckbox?: boolean;\n  showUninstall?: boolean;\n}) {\n  const updates = usePluginUpdates();\n  const latestVersion = updates.data?.plugins.find((u) => u.name === name)?.version;\n  const installPluginMutation = useMutation({\n    mutationKey: [\"install_plugin\", name],\n    mutationFn: (name: string) => installPlugin(name, null),\n  });\n  const uninstall = usePromptUninstall(plugin?.id ?? null, displayName);\n  const refreshPlugins = useRefreshPlugins();\n\n  return (\n    <TableRow>\n      {showCheckbox && (\n        <TableCell className=\"!py-0\">\n          <Checkbox\n            hideLabel\n            title={plugin?.enabled ? \"Disable plugin\" : \"Enable plugin\"}\n            checked={plugin?.enabled ?? false}\n            disabled={plugin == null}\n            onChange={async (enabled) => {\n              if (plugin) {\n                await patchModel(plugin, { enabled });\n                refreshPlugins.mutate();\n              }\n            }}\n          />\n        </TableCell>\n      )}\n      <TableCell className=\"font-semibold\">\n        {url ? (\n          <Link noUnderline href={url}>\n            {displayName}\n          </Link>\n        ) : (\n          displayName\n        )}\n      </TableCell>\n      <TableCell>\n        <InlineCode>{name}</InlineCode>\n      </TableCell>\n      <TableCell>\n        <HStack space={1.5}>\n          <InlineCode>{version}</InlineCode>\n          {latestVersion != null && (\n            <InlineCode className=\"text-success flex items-center gap-1\">\n              <Icon icon=\"arrow_up\" size=\"sm\" />\n              {latestVersion}\n            </InlineCode>\n          )}\n        </HStack>\n      </TableCell>\n      <TableCell className=\"!py-0\">\n        <HStack justifyContent=\"end\" space={1.5}>\n          {plugin != null && latestVersion != null ? (\n            <Button\n              variant=\"border\"\n              color=\"success\"\n              title={`Update to ${latestVersion}`}\n              size=\"xs\"\n              isLoading={installPluginMutation.isPending}\n              onClick={() => installPluginMutation.mutate(name)}\n            >\n              Update\n            </Button>\n          ) : plugin == null ? (\n            <Button\n              variant=\"border\"\n              color=\"primary\"\n              title={`Install ${version}`}\n              size=\"xs\"\n              isLoading={installPluginMutation.isPending}\n              onClick={() => installPluginMutation.mutate(name)}\n            >\n              Install\n            </Button>\n          ) : null}\n          {showUninstall && uninstall != null && (\n            <Button\n              size=\"xs\"\n              title=\"Uninstall plugin\"\n              variant=\"border\"\n              isLoading={uninstall.isPending}\n              onClick={() => uninstall.mutate()}\n            >\n              Uninstall\n            </Button>\n          )}\n        </HStack>\n      </TableCell>\n    </TableRow>\n  );\n}\n\nfunction PluginSearch() {\n  const [query, setQuery] = useState<string>(\"\");\n  const debouncedQuery = useDebouncedValue(query);\n  const results = useQuery({\n    queryKey: [\"plugins\", debouncedQuery],\n    queryFn: () => searchPlugins(query),\n  });\n\n  return (\n    <div className=\"h-full grid grid-rows-[auto_minmax(0,1fr)] gap-3\">\n      <HStack space={1.5}>\n        <PlainInput\n          hideLabel\n          label=\"Search\"\n          placeholder=\"Search plugins...\"\n          onChange={setQuery}\n          defaultValue={query}\n        />\n      </HStack>\n      <div className=\"w-full h-full\">\n        {results.data == null ? (\n          <EmptyStateText>\n            <LoadingIcon size=\"xl\" className=\"text-text-subtlest\" />\n          </EmptyStateText>\n        ) : (results.data.plugins ?? []).length === 0 ? (\n          <EmptyStateText>No plugins found</EmptyStateText>\n        ) : (\n          <Table scrollable>\n            <TableHead>\n              <TableRow>\n                <TableHeaderCell>Display Name</TableHeaderCell>\n                <TableHeaderCell>Name</TableHeaderCell>\n                <TableHeaderCell>Version</TableHeaderCell>\n                <TableHeaderCell />\n              </TableRow>\n            </TableHead>\n            <TableBody>\n              {results.data.plugins.map((p) => (\n                <PluginTableRowForRemotePluginVersion key={p.id} pluginVersion={p} />\n              ))}\n            </TableBody>\n          </Table>\n        )}\n      </div>\n    </div>\n  );\n}\n\nfunction InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className?: string }) {\n  return plugins.length === 0 ? (\n    <div className={classNames(className, \"pb-4\")}>\n      <EmptyStateText className=\"text-center\">\n        Plugins extend the functionality of Yaak.\n        <br />\n        Add your first plugin to get started.\n      </EmptyStateText>\n    </div>\n  ) : (\n    <Table scrollable className={className}>\n      <TableHead>\n        <TableRow>\n          <TableHeaderCell className=\"w-0\" />\n          <TableHeaderCell>Display Name</TableHeaderCell>\n          <TableHeaderCell>Name</TableHeaderCell>\n          <TableHeaderCell>Version</TableHeaderCell>\n          <TableHeaderCell />\n        </TableRow>\n      </TableHead>\n      <tbody className=\"divide-y divide-surface-highlight\">\n        {plugins.map((p) => (\n          <PluginTableRowForInstalledPlugin key={p.id} plugin={p} />\n        ))}\n      </tbody>\n    </Table>\n  );\n}\n\nfunction BundledPlugins({ plugins }: { plugins: Plugin[] }) {\n  return plugins.length === 0 ? (\n    <div className=\"pb-4\">\n      <EmptyStateText className=\"text-center\">No bundled plugins found.</EmptyStateText>\n    </div>\n  ) : (\n    <Table scrollable>\n      <TableHead>\n        <TableRow>\n          <TableHeaderCell className=\"w-0\" />\n          <TableHeaderCell>Display Name</TableHeaderCell>\n          <TableHeaderCell>Name</TableHeaderCell>\n          <TableHeaderCell>Version</TableHeaderCell>\n          <TableHeaderCell />\n        </TableRow>\n      </TableHead>\n      <tbody className=\"divide-y divide-surface-highlight\">\n        {plugins.map((p) => (\n          <PluginTableRowForBundledPlugin key={p.id} plugin={p} />\n        ))}\n      </tbody>\n    </Table>\n  );\n}\n\nfunction usePromptUninstall(pluginId: string | null, name: string) {\n  const mut = useMutation({\n    mutationKey: [\"uninstall_plugin\", pluginId],\n    mutationFn: async () => {\n      if (pluginId == null) return;\n\n      const confirmed = await showConfirmDelete({\n        id: `uninstall-plugin-${pluginId}`,\n        title: \"Uninstall Plugin\",\n        confirmText: \"Uninstall\",\n        description: (\n          <>\n            Permanently uninstall <InlineCode>{name}</InlineCode>?\n          </>\n        ),\n      });\n      if (confirmed) {\n        await minPromiseMillis(uninstallPlugin(pluginId), 700);\n      }\n    },\n  });\n\n  return pluginId == null ? null : mut;\n}\n\nfunction usePluginUpdates() {\n  return useQuery({\n    queryKey: [\"plugin_updates\", usePluginsKey()],\n    queryFn: () => checkPluginUpdates(),\n  });\n}\n"
  },
  {
    "path": "src-web/components/Settings/SettingsProxy.tsx",
    "content": "import { patchModel, settingsAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\n\nimport { Checkbox } from \"../core/Checkbox\";\nimport { Heading } from \"../core/Heading\";\nimport { InlineCode } from \"../core/InlineCode\";\nimport { PlainInput } from \"../core/PlainInput\";\nimport { Select } from \"../core/Select\";\nimport { Separator } from \"../core/Separator\";\nimport { HStack, VStack } from \"../core/Stacks\";\n\nexport function SettingsProxy() {\n  const settings = useAtomValue(settingsAtom);\n\n  return (\n    <VStack space={1.5} className=\"mb-4\">\n      <div className=\"mb-3\">\n        <Heading>Proxy</Heading>\n        <p className=\"text-text-subtle\">\n          Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging\n          traffic, or routing through specific infrastructure.\n        </p>\n      </div>\n      <Select\n        name=\"proxy\"\n        label=\"Proxy\"\n        hideLabel\n        size=\"sm\"\n        value={settings.proxy?.type ?? \"automatic\"}\n        onChange={async (v) => {\n          if (v === \"automatic\") {\n            await patchModel(settings, { proxy: undefined });\n          } else if (v === \"enabled\") {\n            await patchModel(settings, {\n              proxy: {\n                disabled: false,\n                type: \"enabled\",\n                http: \"\",\n                https: \"\",\n                auth: { user: \"\", password: \"\" },\n                bypass: \"\",\n              },\n            });\n          } else {\n            await patchModel(settings, { proxy: { type: \"disabled\" } });\n          }\n        }}\n        options={[\n          { label: \"Automatic proxy detection\", value: \"automatic\" },\n          { label: \"Custom proxy configuration\", value: \"enabled\" },\n          { label: \"No proxy\", value: \"disabled\" },\n        ]}\n      />\n      {settings.proxy?.type === \"enabled\" && (\n        <VStack space={1.5}>\n          <Checkbox\n            className=\"my-3\"\n            checked={!settings.proxy.disabled}\n            title=\"Enable proxy\"\n            help=\"Use this to temporarily disable the proxy without losing the configuration\"\n            onChange={async (enabled) => {\n              const { proxy } = settings;\n              const http = proxy?.type === \"enabled\" ? proxy.http : \"\";\n              const https = proxy?.type === \"enabled\" ? proxy.https : \"\";\n              const bypass = proxy?.type === \"enabled\" ? proxy.bypass : \"\";\n              const auth = proxy?.type === \"enabled\" ? proxy.auth : null;\n              const disabled = !enabled;\n              await patchModel(settings, {\n                proxy: { type: \"enabled\", http, https, auth, disabled, bypass },\n              });\n            }}\n          />\n          <HStack space={1.5}>\n            <PlainInput\n              size=\"sm\"\n              label={\n                <>\n                  Proxy for <InlineCode>http://</InlineCode> traffic\n                </>\n              }\n              placeholder=\"localhost:9090\"\n              defaultValue={settings.proxy?.http}\n              onChange={async (http) => {\n                const { proxy } = settings;\n                const https = proxy?.type === \"enabled\" ? proxy.https : \"\";\n                const bypass = proxy?.type === \"enabled\" ? proxy.bypass : \"\";\n                const auth = proxy?.type === \"enabled\" ? proxy.auth : null;\n                const disabled = proxy?.type === \"enabled\" ? proxy.disabled : false;\n                await patchModel(settings, {\n                  proxy: {\n                    type: \"enabled\",\n                    http,\n                    https,\n                    auth,\n                    disabled,\n                    bypass,\n                  },\n                });\n              }}\n            />\n            <PlainInput\n              size=\"sm\"\n              label={\n                <>\n                  Proxy for <InlineCode>https://</InlineCode> traffic\n                </>\n              }\n              placeholder=\"localhost:9090\"\n              defaultValue={settings.proxy?.https}\n              onChange={async (https) => {\n                const { proxy } = settings;\n                const http = proxy?.type === \"enabled\" ? proxy.http : \"\";\n                const bypass = proxy?.type === \"enabled\" ? proxy.bypass : \"\";\n                const auth = proxy?.type === \"enabled\" ? proxy.auth : null;\n                const disabled = proxy?.type === \"enabled\" ? proxy.disabled : false;\n                await patchModel(settings, {\n                  proxy: { type: \"enabled\", http, https, auth, disabled, bypass },\n                });\n              }}\n            />\n          </HStack>\n          <Separator className=\"my-6\" />\n          <Checkbox\n            checked={settings.proxy.auth != null}\n            title=\"Enable authentication\"\n            onChange={async (enabled) => {\n              const { proxy } = settings;\n              const http = proxy?.type === \"enabled\" ? proxy.http : \"\";\n              const https = proxy?.type === \"enabled\" ? proxy.https : \"\";\n              const disabled = proxy?.type === \"enabled\" ? proxy.disabled : false;\n              const bypass = proxy?.type === \"enabled\" ? proxy.bypass : \"\";\n              const auth = enabled ? { user: \"\", password: \"\" } : null;\n              await patchModel(settings, {\n                proxy: { type: \"enabled\", http, https, auth, disabled, bypass },\n              });\n            }}\n          />\n\n          {settings.proxy.auth != null && (\n            <HStack space={1.5}>\n              <PlainInput\n                required\n                size=\"sm\"\n                label=\"User\"\n                placeholder=\"myUser\"\n                defaultValue={settings.proxy.auth.user}\n                onChange={async (user) => {\n                  const { proxy } = settings;\n                  const http = proxy?.type === \"enabled\" ? proxy.http : \"\";\n                  const https = proxy?.type === \"enabled\" ? proxy.https : \"\";\n                  const disabled = proxy?.type === \"enabled\" ? proxy.disabled : false;\n                  const bypass = proxy?.type === \"enabled\" ? proxy.bypass : \"\";\n                  const password = proxy?.type === \"enabled\" ? (proxy.auth?.password ?? \"\") : \"\";\n                  const auth = { user, password };\n                  await patchModel(settings, {\n                    proxy: { type: \"enabled\", http, https, auth, disabled, bypass },\n                  });\n                }}\n              />\n              <PlainInput\n                size=\"sm\"\n                label=\"Password\"\n                type=\"password\"\n                placeholder=\"s3cretPassw0rd\"\n                defaultValue={settings.proxy.auth.password}\n                onChange={async (password) => {\n                  const { proxy } = settings;\n                  const http = proxy?.type === \"enabled\" ? proxy.http : \"\";\n                  const https = proxy?.type === \"enabled\" ? proxy.https : \"\";\n                  const disabled = proxy?.type === \"enabled\" ? proxy.disabled : false;\n                  const bypass = proxy?.type === \"enabled\" ? proxy.bypass : \"\";\n                  const user = proxy?.type === \"enabled\" ? (proxy.auth?.user ?? \"\") : \"\";\n                  const auth = { user, password };\n                  await patchModel(settings, {\n                    proxy: { type: \"enabled\", http, https, auth, disabled, bypass },\n                  });\n                }}\n              />\n            </HStack>\n          )}\n          {settings.proxy.type === \"enabled\" && (\n            <>\n              <Separator className=\"my-6\" />\n              <PlainInput\n                label=\"Proxy Bypass\"\n                help=\"Comma-separated list to bypass the proxy.\"\n                defaultValue={settings.proxy.bypass}\n                placeholder=\"127.0.0.1, *.example.com, localhost:3000\"\n                onChange={async (bypass) => {\n                  const { proxy } = settings;\n                  const http = proxy?.type === \"enabled\" ? proxy.http : \"\";\n                  const https = proxy?.type === \"enabled\" ? proxy.https : \"\";\n                  const disabled = proxy?.type === \"enabled\" ? proxy.disabled : false;\n                  const user = proxy?.type === \"enabled\" ? (proxy.auth?.user ?? \"\") : \"\";\n                  const password = proxy?.type === \"enabled\" ? (proxy.auth?.password ?? \"\") : \"\";\n                  const auth = { user, password };\n                  await patchModel(settings, {\n                    proxy: { type: \"enabled\", http, https, auth, disabled, bypass },\n                  });\n                }}\n              />\n            </>\n          )}\n        </VStack>\n      )}\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/Settings/SettingsTheme.tsx",
    "content": "import { patchModel, settingsAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { lazy, Suspense } from \"react\";\nimport { activeWorkspaceAtom } from \"../../hooks/useActiveWorkspace\";\nimport { useResolvedAppearance } from \"../../hooks/useResolvedAppearance\";\nimport { useResolvedTheme } from \"../../hooks/useResolvedTheme\";\nimport type { ButtonProps } from \"../core/Button\";\nimport { Heading } from \"../core/Heading\";\nimport type { IconProps } from \"../core/Icon\";\nimport { Icon } from \"../core/Icon\";\nimport { IconButton } from \"../core/IconButton\";\nimport { Link } from \"../core/Link\";\nimport type { SelectProps } from \"../core/Select\";\nimport { Select } from \"../core/Select\";\nimport { HStack, VStack } from \"../core/Stacks\";\n\nconst Editor = lazy(() => import(\"../core/Editor/Editor\").then((m) => ({ default: m.Editor })));\n\nconst buttonColors: ButtonProps[\"color\"][] = [\n  \"primary\",\n  \"info\",\n  \"success\",\n  \"notice\",\n  \"warning\",\n  \"danger\",\n  \"secondary\",\n  \"default\",\n];\n\nconst icons: IconProps[\"icon\"][] = [\n  \"info\",\n  \"box\",\n  \"update\",\n  \"alert_triangle\",\n  \"arrow_big_right_dash\",\n  \"download\",\n  \"copy\",\n  \"magic_wand\",\n  \"settings\",\n  \"trash\",\n  \"sparkles\",\n  \"pencil\",\n  \"paste\",\n  \"search\",\n  \"send_horizontal\",\n];\n\nexport function SettingsTheme() {\n  const workspace = useAtomValue(activeWorkspaceAtom);\n  const settings = useAtomValue(settingsAtom);\n  const appearance = useResolvedAppearance();\n  const activeTheme = useResolvedTheme();\n\n  if (settings == null || workspace == null || activeTheme.data == null) {\n    return null;\n  }\n\n  const lightThemes: SelectProps<string>[\"options\"] = activeTheme.data.themes\n    .filter((theme) => !theme.dark)\n    .map((theme) => ({\n      label: theme.label,\n      value: theme.id,\n    }));\n\n  const darkThemes: SelectProps<string>[\"options\"] = activeTheme.data.themes\n    .filter((theme) => theme.dark)\n    .map((theme) => ({\n      label: theme.label,\n      value: theme.id,\n    }));\n\n  return (\n    <VStack space={3} className=\"mb-4\">\n      <div className=\"mb-3\">\n        <Heading>Theme</Heading>\n        <p className=\"text-text-subtle\">\n          Make Yaak your own by selecting a theme, or{\" \"}\n          <Link href=\"https://yaak.app/docs/plugin-development/plugins-quick-start\">\n            Create Your Own\n          </Link>\n        </p>\n      </div>\n      <Select\n        name=\"appearance\"\n        label=\"Appearance\"\n        labelPosition=\"top\"\n        size=\"sm\"\n        value={settings.appearance}\n        onChange={(appearance) => patchModel(settings, { appearance })}\n        options={[\n          { label: \"Automatic\", value: \"system\" },\n          { label: \"Light\", value: \"light\" },\n          { label: \"Dark\", value: \"dark\" },\n        ]}\n      />\n      <HStack space={2}>\n        {(settings.appearance === \"system\" || settings.appearance === \"light\") && (\n          <Select\n            hideLabel\n            leftSlot={<Icon icon=\"sun\" color=\"secondary\" />}\n            name=\"lightTheme\"\n            label=\"Light Theme\"\n            size=\"sm\"\n            className=\"flex-1\"\n            value={activeTheme.data.light.id}\n            options={lightThemes}\n            onChange={(themeLight) => patchModel(settings, { themeLight })}\n          />\n        )}\n        {(settings.appearance === \"system\" || settings.appearance === \"dark\") && (\n          <Select\n            hideLabel\n            name=\"darkTheme\"\n            className=\"flex-1\"\n            label=\"Dark Theme\"\n            leftSlot={<Icon icon=\"moon\" color=\"secondary\" />}\n            size=\"sm\"\n            value={activeTheme.data.dark.id}\n            options={darkThemes}\n            onChange={(themeDark) => patchModel(settings, { themeDark })}\n          />\n        )}\n      </HStack>\n\n      <VStack\n        space={3}\n        className=\"mt-3 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto\"\n      >\n        <HStack className=\"text\" space={1.5}>\n          <Icon icon={appearance === \"dark\" ? \"moon\" : \"sun\"} />\n          <strong>{activeTheme.data.active.label}</strong>\n          <em>(preview)</em>\n        </HStack>\n        <HStack space={1.5} className=\"w-full\">\n          {buttonColors.map((c, i) => (\n            <IconButton\n              key={c}\n              color={c}\n              size=\"2xs\"\n              iconSize=\"xs\"\n              icon={icons[i % icons.length] ?? \"info\"}\n              iconClassName=\"text\"\n              title={`${c}`}\n            />\n          ))}\n          {buttonColors.map((c, i) => (\n            <IconButton\n              key={c}\n              color={c}\n              variant=\"border\"\n              size=\"2xs\"\n              iconSize=\"xs\"\n              icon={icons[i % icons.length] ?? \"info\"}\n              iconClassName=\"text\"\n              title={`${c}`}\n            />\n          ))}\n        </HStack>\n        <Suspense>\n          <Editor\n            defaultValue={[\n              \"let foo = { // Demo code editor\",\n              '  foo: (\"bar\" || \"baz\" ?? \\'qux\\'),',\n              \"  baz: [1, 10.2, null, false, true],\",\n              \"};\",\n            ].join(\"\\n\")}\n            heightMode=\"auto\"\n            language=\"javascript\"\n            stateKey={null}\n          />\n        </Suspense>\n      </VStack>\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/SettingsDropdown.tsx",
    "content": "import { openUrl } from \"@tauri-apps/plugin-opener\";\nimport { useLicense } from \"@yaakapp-internal/license\";\nimport { useRef } from \"react\";\nimport { openSettings } from \"../commands/openSettings\";\nimport { useCheckForUpdates } from \"../hooks/useCheckForUpdates\";\nimport { useExportData } from \"../hooks/useExportData\";\nimport { appInfo } from \"../lib/appInfo\";\nimport { showDialog } from \"../lib/dialog\";\nimport { importData } from \"../lib/importData\";\nimport type { DropdownRef } from \"./core/Dropdown\";\nimport { Dropdown } from \"./core/Dropdown\";\nimport { Icon } from \"./core/Icon\";\nimport { IconButton } from \"./core/IconButton\";\nimport { KeyboardShortcutsDialog } from \"./KeyboardShortcutsDialog\";\n\nexport function SettingsDropdown() {\n  const exportData = useExportData();\n  const dropdownRef = useRef<DropdownRef>(null);\n  const checkForUpdates = useCheckForUpdates();\n  const { check } = useLicense();\n\n  return (\n    <Dropdown\n      ref={dropdownRef}\n      items={[\n        {\n          label: \"Settings\",\n          hotKeyAction: \"settings.show\",\n          leftSlot: <Icon icon=\"settings\" />,\n          onSelect: () => openSettings.mutate(null),\n        },\n        {\n          label: \"Keyboard shortcuts\",\n          hotKeyAction: \"hotkeys.showHelp\",\n          leftSlot: <Icon icon=\"keyboard\" />,\n          onSelect: () => {\n            showDialog({\n              id: \"hotkey\",\n              title: \"Keyboard Shortcuts\",\n              size: \"dynamic\",\n              render: () => <KeyboardShortcutsDialog />,\n            });\n          },\n        },\n        {\n          label: \"Plugins\",\n          leftSlot: <Icon icon=\"puzzle\" />,\n          onSelect: () => openSettings.mutate(\"plugins\"),\n        },\n        { type: \"separator\", label: \"Share Workspace(s)\" },\n        {\n          label: \"Import Data\",\n          leftSlot: <Icon icon=\"folder_input\" />,\n          onSelect: () => importData.mutate(),\n        },\n        {\n          label: \"Export Data\",\n          leftSlot: <Icon icon=\"folder_output\" />,\n          onSelect: () => exportData.mutate(),\n        },\n        {\n          label: \"Create Run Button\",\n          leftSlot: <Icon icon=\"rocket\" />,\n          onSelect: () => openUrl(\"https://yaak.app/button/new\"),\n        },\n        { type: \"separator\", label: `Yaak v${appInfo.version}` },\n        {\n          label: \"Check for Updates\",\n          leftSlot: <Icon icon=\"update\" />,\n          hidden: !appInfo.featureUpdater,\n          onSelect: () => checkForUpdates.mutate(),\n        },\n        {\n          label: \"Purchase License\",\n          color: \"success\",\n          hidden: check.data == null || check.data.status === \"active\",\n          leftSlot: <Icon icon=\"circle_dollar_sign\" />,\n          rightSlot: <Icon icon=\"external_link\" color=\"success\" className=\"opacity-60\" />,\n          onSelect: () => openUrl(\"https://yaak.app/pricing\"),\n        },\n        {\n          label: \"Install CLI\",\n          hidden: appInfo.cliVersion != null,\n          leftSlot: <Icon icon=\"square_terminal\" />,\n          rightSlot: <Icon icon=\"external_link\" color=\"secondary\" />,\n          onSelect: () => openUrl(\"https://yaak.app/docs/cli\"),\n        },\n        {\n          label: \"Feedback\",\n          leftSlot: <Icon icon=\"chat\" />,\n          rightSlot: <Icon icon=\"external_link\" color=\"secondary\" />,\n          onSelect: () => openUrl(\"https://yaak.app/feedback\"),\n        },\n        {\n          label: \"Changelog\",\n          leftSlot: <Icon icon=\"cake\" />,\n          rightSlot: <Icon icon=\"external_link\" color=\"secondary\" />,\n          onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`),\n        },\n      ]}\n    >\n      <IconButton\n        size=\"sm\"\n        title=\"Main Menu\"\n        icon=\"settings\"\n        iconColor=\"secondary\"\n        className=\"pointer-events-auto\"\n      />\n    </Dropdown>\n  );\n}\n"
  },
  {
    "path": "src-web/components/Sidebar.tsx",
    "content": "import type { Extension } from \"@codemirror/state\";\nimport { Compartment } from \"@codemirror/state\";\nimport { debounce } from \"@yaakapp-internal/lib\";\nimport type {\n  AnyModel,\n  Folder,\n  GrpcRequest,\n  HttpRequest,\n  ModelPayload,\n  WebsocketRequest,\n  Workspace,\n} from \"@yaakapp-internal/models\";\nimport {\n  duplicateModel,\n  foldersAtom,\n  getAnyModel,\n  getModel,\n  grpcConnectionsAtom,\n  httpResponsesAtom,\n  patchModel,\n  websocketConnectionsAtom,\n  workspacesAtom,\n} from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { atom, useAtomValue } from \"jotai\";\nimport { selectAtom } from \"jotai/utils\";\nimport { memo, useCallback, useEffect, useMemo, useRef } from \"react\";\nimport { moveToWorkspace } from \"../commands/moveToWorkspace\";\nimport { openFolderSettings } from \"../commands/openFolderSettings\";\nimport { activeFolderIdAtom } from \"../hooks/useActiveFolderId\";\nimport { activeRequestIdAtom } from \"../hooks/useActiveRequestId\";\nimport { activeWorkspaceAtom, activeWorkspaceIdAtom } from \"../hooks/useActiveWorkspace\";\nimport { allRequestsAtom } from \"../hooks/useAllRequests\";\nimport { getCreateDropdownItems } from \"../hooks/useCreateDropdownItems\";\nimport { getFolderActions } from \"../hooks/useFolderActions\";\nimport { getGrpcRequestActions } from \"../hooks/useGrpcRequestActions\";\nimport { useHotKey } from \"../hooks/useHotKey\";\nimport { getHttpRequestActions } from \"../hooks/useHttpRequestActions\";\nimport { useListenToTauriEvent } from \"../hooks/useListenToTauriEvent\";\nimport { getModelAncestors } from \"../hooks/useModelAncestors\";\nimport { sendAnyHttpRequest } from \"../hooks/useSendAnyHttpRequest\";\nimport { useSidebarHidden } from \"../hooks/useSidebarHidden\";\nimport { getWebsocketRequestActions } from \"../hooks/useWebsocketRequestActions\";\nimport { deepEqualAtom } from \"../lib/atoms\";\nimport { deleteModelWithConfirm } from \"../lib/deleteModelWithConfirm\";\nimport { fireAndForget } from \"../lib/fireAndForget\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { resolvedModelName } from \"../lib/resolvedModelName\";\nimport { isSidebarFocused } from \"../lib/scopes\";\nimport { navigateToRequestOrFolderOrWorkspace } from \"../lib/setWorkspaceSearchParams\";\nimport type { ContextMenuProps, DropdownItem } from \"./core/Dropdown\";\nimport { Dropdown } from \"./core/Dropdown\";\nimport type { FieldDef } from \"./core/Editor/filter/extension\";\nimport { filter } from \"./core/Editor/filter/extension\";\nimport { evaluate, parseQuery } from \"./core/Editor/filter/query\";\nimport { HttpMethodTag } from \"./core/HttpMethodTag\";\nimport { HttpStatusTag } from \"./core/HttpStatusTag\";\nimport { Icon } from \"./core/Icon\";\nimport { IconButton } from \"./core/IconButton\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport type { InputHandle } from \"./core/Input\";\nimport { Input } from \"./core/Input\";\nimport { LoadingIcon } from \"./core/LoadingIcon\";\nimport { collapsedFamily, isSelectedFamily, selectedIdsFamily } from \"./core/tree/atoms\";\nimport type { TreeNode } from \"./core/tree/common\";\nimport type { TreeHandle, TreeProps } from \"./core/tree/Tree\";\nimport { Tree } from \"./core/tree/Tree\";\nimport type { TreeItemProps } from \"./core/tree/TreeItem\";\nimport { GitDropdown } from \"./git/GitDropdown\";\n\ntype SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;\nfunction isSidebarLeafModel(m: AnyModel): boolean {\n  const modelMap: Record<Exclude<SidebarModel[\"model\"], \"workspace\">, null> = {\n    http_request: null,\n    grpc_request: null,\n    websocket_request: null,\n    folder: null,\n  };\n  return m.model in modelMap;\n}\n\nconst OPACITY_SUBTLE = \"opacity-80\";\n\nfunction Sidebar({ className }: { className?: string }) {\n  const [hidden, setHidden] = useSidebarHidden();\n  const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;\n  const treeId = `tree.${activeWorkspaceId ?? \"unknown\"}`;\n  const filterText = useAtomValue(sidebarFilterAtom);\n  const [tree, allFields] = useAtomValue(sidebarTreeAtom) ?? [];\n  const wrapperRef = useRef<HTMLElement>(null);\n  const treeRef = useRef<TreeHandle>(null);\n  const filterRef = useRef<InputHandle>(null);\n  const setFilterRef = useCallback((h: InputHandle | null) => {\n    filterRef.current = h;\n  }, []);\n  const allHidden = useMemo(() => {\n    if (tree?.children?.length === 0) return false;\n    if (filterText) return tree?.children?.every((c) => c.hidden);\n    return true;\n  }, [filterText, tree?.children]);\n\n  const focusActiveItem = useCallback(() => {\n    const didFocus = treeRef.current?.focus();\n    // If we weren't able to focus any items, focus the filter bar\n    if (!didFocus) filterRef.current?.focus();\n  }, []);\n\n  // Focus any new sidebar models when created\n  useListenToTauriEvent<ModelPayload>(\"model_write\", ({ payload }) => {\n    if (!isSidebarLeafModel(payload.model)) return;\n    if (!(payload.change.type === \"upsert\" && payload.change.created)) return;\n    treeRef.current?.selectItem(payload.model.id, true);\n  });\n\n  useEffect(() => {\n    return jotaiStore.sub(activeIdAtom, () => {\n      const activeId = jotaiStore.get(activeIdAtom);\n      if (activeId) {\n        treeRef.current?.selectItem(activeId, true);\n      }\n    });\n  }, []);\n\n  useHotKey(\n    \"sidebar.filter\",\n    () => {\n      filterRef.current?.focus();\n    },\n    {\n      enable: isSidebarFocused,\n    },\n  );\n\n  useHotKey(\"sidebar.focus\", async function focusHotkey() {\n    // Hide the sidebar if it's already focused\n    if (!hidden && isSidebarFocused()) {\n      await setHidden(true);\n      return;\n    }\n\n    // Show the sidebar if it's hidden\n    if (hidden) {\n      await setHidden(false);\n    }\n\n    // Select the 0th index on focus if none selected\n    setTimeout(focusActiveItem, 100);\n  });\n\n  const handleDragEnd = useCallback(async function handleDragEnd({\n    items,\n    parent,\n    children,\n    insertAt,\n  }: {\n    items: SidebarModel[];\n    parent: SidebarModel;\n    children: SidebarModel[];\n    insertAt: number;\n  }) {\n    const prev = children[insertAt - 1] as Exclude<SidebarModel, Workspace>;\n    const next = children[insertAt] as Exclude<SidebarModel, Workspace>;\n    const folderId = parent.model === \"folder\" ? parent.id : null;\n\n    const beforePriority = prev?.sortPriority ?? 0;\n    const afterPriority = next?.sortPriority ?? 0;\n    const shouldUpdateAll = afterPriority - beforePriority < 1;\n\n    try {\n      if (shouldUpdateAll) {\n        // Add items to children at insertAt\n        children.splice(insertAt, 0, ...items);\n        await Promise.all(\n          children.map((m, i) => patchModel(m, { sortPriority: i * 1000, folderId })),\n        );\n      } else {\n        const range = afterPriority - beforePriority;\n        const increment = range / (items.length + 2);\n        await Promise.all(\n          items.map((m, i) =>\n            // Spread item sortPriority out over before/after range\n            patchModel(m, {\n              sortPriority: beforePriority + (i + 1) * increment,\n              folderId,\n            }),\n          ),\n        );\n      }\n    } catch (e) {\n      console.error(e);\n    }\n  }, []);\n\n  const handleTreeRefInit = useCallback(\n    (n: TreeHandle) => {\n      treeRef.current = n;\n      if (n == null) return;\n      const activeId = jotaiStore.get(activeIdAtom);\n      if (activeId == null) return;\n      const selectedIds = jotaiStore.get(selectedIdsFamily(treeId));\n      if (selectedIds.length > 0) return;\n      n.selectItem(activeId);\n    },\n    [treeId],\n  );\n\n  const clearFilterText = useCallback(() => {\n    jotaiStore.set(sidebarFilterAtom, { text: \"\", key: `${Math.random()}` });\n    requestAnimationFrame(() => {\n      filterRef.current?.focus();\n    });\n  }, []);\n\n  const handleFilterKeyDown = useCallback(\n    (e: KeyboardEvent) => {\n      e.stopPropagation(); // Don't trigger tree navigation hotkeys\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        clearFilterText();\n      }\n    },\n    [clearFilterText],\n  );\n\n  const handleFilterChange = useMemo(\n    () =>\n      debounce((text: string) => {\n        jotaiStore.set(sidebarFilterAtom, (prev) => ({ ...prev, text }));\n      }, 0),\n    [],\n  );\n\n  const actions = useMemo(() => {\n    const enable = () => treeRef.current?.hasFocus() ?? false;\n\n    const actions = {\n      \"sidebar.context_menu\": {\n        enable,\n        cb: () => treeRef.current?.showContextMenu(),\n      },\n      \"sidebar.expand_all\": {\n        enable: isSidebarFocused,\n        cb: () => {\n          jotaiStore.set(collapsedFamily(treeId), {});\n        },\n      },\n      \"sidebar.collapse_all\": {\n        enable: isSidebarFocused,\n        cb: () => {\n          if (tree == null) return;\n\n          const next = (node: TreeNode<SidebarModel>, collapsed: Record<string, boolean>) => {\n            let newCollapsed = { ...collapsed };\n            for (const n of node.children ?? []) {\n              if (n.item.model !== \"folder\") continue;\n              newCollapsed[n.item.id] = true;\n              newCollapsed = next(n, newCollapsed);\n            }\n            return newCollapsed;\n          };\n          const collapsed = next(tree, {});\n          jotaiStore.set(collapsedFamily(treeId), collapsed);\n        },\n      },\n      \"sidebar.selected.delete\": {\n        enable,\n        cb: async (items: SidebarModel[]) => {\n          await deleteModelWithConfirm(items);\n        },\n      },\n      \"sidebar.selected.rename\": {\n        enable,\n        allowDefault: true,\n        cb: async (items: SidebarModel[]) => {\n          const item = items[0];\n          if (items.length === 1 && item != null) {\n            treeRef.current?.renameItem(item.id);\n          }\n        },\n      },\n      \"sidebar.selected.duplicate\": {\n        // Higher priority so this takes precedence over model.duplicate (same Meta+d binding)\n        priority: 10,\n        enable,\n        cb: async (items: SidebarModel[]) => {\n          if (items.length === 1 && items[0]) {\n            const item = items[0];\n            const newId = await duplicateModel(item);\n            navigateToRequestOrFolderOrWorkspace(newId, item.model);\n          } else {\n            await Promise.all(items.map(duplicateModel));\n          }\n        },\n      },\n      \"sidebar.selected.move\": {\n        enable,\n        cb: async (items: SidebarModel[]) => {\n          const requests = items.filter(\n            (i): i is HttpRequest | GrpcRequest | WebsocketRequest =>\n              i.model === \"http_request\" ||\n              i.model === \"grpc_request\" ||\n              i.model === \"websocket_request\",\n          );\n          if (requests.length > 0) {\n            moveToWorkspace.mutate(requests);\n          }\n        },\n      },\n      \"request.send\": {\n        enable,\n        cb: async (items: SidebarModel[]) => {\n          await Promise.all(\n            items\n              .filter((i) => i.model === \"http_request\")\n              .map((i) => sendAnyHttpRequest.mutate(i.id)),\n          );\n        },\n      },\n    } as const;\n    return actions;\n  }, [tree, treeId]);\n\n  const getContextMenu = useCallback<(items: SidebarModel[]) => Promise<DropdownItem[]>>(\n    async (items) => {\n      const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n      const child = items[0];\n\n      // No children means we're in the root\n      if (child == null) {\n        return getCreateDropdownItems({\n          workspaceId,\n          activeRequest: null,\n          folderId: null,\n        });\n      }\n\n      const workspaces = jotaiStore.get(workspacesAtom);\n      const onlyHttpRequests = items.every((i) => i.model === \"http_request\");\n      const requestItems = items.filter(\n        (i) =>\n          i.model === \"http_request\" ||\n          i.model === \"grpc_request\" ||\n          i.model === \"websocket_request\",\n      );\n\n      const initialItems: ContextMenuProps[\"items\"] = [\n        {\n          label: \"Folder Settings\",\n          hidden: !(items.length === 1 && child.model === \"folder\"),\n          leftSlot: <Icon icon=\"folder_cog\" />,\n          onSelect: () => openFolderSettings(child.id),\n        },\n        {\n          label: \"Send\",\n          hotKeyAction: \"request.send\",\n          hotKeyLabelOnly: true,\n          hidden: !onlyHttpRequests,\n          leftSlot: <Icon icon=\"send_horizontal\" />,\n          onSelect: () => actions[\"request.send\"].cb(items),\n        },\n        ...(items.length === 1 && child.model === \"http_request\"\n          ? await getHttpRequestActions()\n          : []\n        ).map((a) => ({\n          label: a.label,\n          leftSlot: <Icon icon={a.icon ?? \"empty\"} />,\n          onSelect: async () => {\n            const request = getModel(\"http_request\", child.id);\n            if (request != null) await a.call(request);\n          },\n        })),\n        ...(items.length === 1 && child.model === \"grpc_request\"\n          ? await getGrpcRequestActions()\n          : []\n        ).map((a) => ({\n          label: a.label,\n          // eslint-disable-next-line @typescript-eslint/no-explicit-any\n          leftSlot: <Icon icon={a.icon ?? \"empty\"} />,\n          onSelect: async () => {\n            const request = getModel(\"grpc_request\", child.id);\n            if (request != null) await a.call(request);\n          },\n        })),\n        ...(items.length === 1 && child.model === \"websocket_request\"\n          ? await getWebsocketRequestActions()\n          : []\n        ).map((a) => ({\n          label: a.label,\n          leftSlot: <Icon icon={a.icon ?? \"empty\"} />,\n          onSelect: async () => {\n            const request = getModel(\"websocket_request\", child.id);\n            if (request != null) await a.call(request);\n          },\n        })),\n        ...(items.length === 1 && child.model === \"folder\" ? await getFolderActions() : []).map(\n          (a) => ({\n            label: a.label,\n            leftSlot: <Icon icon={a.icon ?? \"empty\"} />,\n            onSelect: async () => {\n              const model = getModel(\"folder\", child.id);\n              if (model != null) await a.call(model);\n            },\n          }),\n        ),\n      ];\n      const modelCreationItems: DropdownItem[] =\n        items.length === 1 && child.model === \"folder\"\n          ? [\n              { type: \"separator\" },\n              ...getCreateDropdownItems({\n                workspaceId,\n                activeRequest: null,\n                folderId: child.id,\n              }),\n            ]\n          : [];\n      const menuItems: ContextMenuProps[\"items\"] = [\n        ...initialItems,\n        {\n          type: \"separator\",\n          hidden: initialItems.filter((v) => !v.hidden).length === 0,\n        },\n        {\n          label: \"Rename\",\n          leftSlot: <Icon icon=\"pencil\" />,\n          hidden: items.length > 1,\n          hotKeyAction: \"sidebar.selected.rename\",\n          hotKeyLabelOnly: true,\n          onSelect: () => {\n            treeRef.current?.renameItem(child.id);\n          },\n        },\n        {\n          label: \"Duplicate\",\n          hotKeyAction: \"model.duplicate\",\n          hotKeyLabelOnly: true, // Would trigger for every request (bad)\n          leftSlot: <Icon icon=\"copy\" />,\n          onSelect: () => actions[\"sidebar.selected.duplicate\"].cb(items),\n        },\n        {\n          label: items.length <= 1 ? \"Move\" : `Move ${requestItems.length} Requests`,\n          hotKeyAction: \"sidebar.selected.move\",\n          hotKeyLabelOnly: true,\n          leftSlot: <Icon icon=\"arrow_right_circle\" />,\n          hidden:\n            workspaces.length <= 1 ||\n            requestItems.length === 0 ||\n            requestItems.length !== items.length,\n          onSelect: () => {\n            fireAndForget(actions[\"sidebar.selected.move\"].cb(items));\n          },\n        },\n        {\n          color: \"danger\",\n          label: \"Delete\",\n          hotKeyAction: \"sidebar.selected.delete\",\n          hotKeyLabelOnly: true,\n          leftSlot: <Icon icon=\"trash\" />,\n          onSelect: () => actions[\"sidebar.selected.delete\"].cb(items),\n        },\n        ...modelCreationItems,\n      ];\n      return menuItems;\n    },\n    [actions],\n  );\n\n  const hotkeys = useMemo<TreeProps<SidebarModel>[\"hotkeys\"]>(() => ({ actions }), [actions]);\n\n  // Use a language compartment for the filter so we can reconfigure it when the autocompletion changes\n  const filterLanguageCompartmentRef = useRef(new Compartment());\n  const filterCompartmentMountExtRef = useRef<Extension | null>(null);\n  if (filterCompartmentMountExtRef.current == null) {\n    filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of(\n      filter({ fields: allFields ?? [] }),\n    );\n  }\n\n  useEffect(() => {\n    const view = filterRef.current;\n    if (!view) return;\n    const ext = filter({ fields: allFields ?? [] });\n    view.dispatch({\n      effects: filterLanguageCompartmentRef.current.reconfigure(ext),\n    });\n  }, [allFields]);\n\n  if (tree == null || hidden) {\n    return null;\n  }\n\n  return (\n    <aside\n      ref={wrapperRef}\n      aria-hidden={hidden ?? undefined}\n      className={classNames(className, \"h-full grid grid-rows-[auto_minmax(0,1fr)_auto]\")}\n    >\n      <div className=\"w-full pl-3 pr-0.5 pt-3 grid grid-cols-[minmax(0,1fr)_auto] items-center\">\n        {(tree.children?.length ?? 0) > 0 && (\n          <>\n            <Input\n              hideLabel\n              setRef={setFilterRef}\n              size=\"sm\"\n              label=\"filter\"\n              language={null} // Explicitly disable\n              placeholder=\"Search\"\n              onChange={handleFilterChange}\n              defaultValue={filterText.text}\n              forceUpdateKey={filterText.key}\n              onKeyDown={handleFilterKeyDown}\n              stateKey={null}\n              wrapLines={false}\n              extraExtensions={filterCompartmentMountExtRef.current ?? undefined}\n              rightSlot={\n                filterText.text && (\n                  <IconButton\n                    className=\"!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1\"\n                    icon=\"x\"\n                    title=\"Clear filter\"\n                    onClick={clearFilterText}\n                  />\n                )\n              }\n            />\n            <Dropdown\n              items={[\n                {\n                  label: \"Focus Active Request\",\n                  leftSlot: <Icon icon=\"crosshair\" />,\n                  onSelect: () => {\n                    const activeId = jotaiStore.get(activeIdAtom);\n                    if (activeId == null) return;\n\n                    const folders = jotaiStore.get(foldersAtom);\n                    const workspaces = jotaiStore.get(workspacesAtom);\n                    const currentModel = getAnyModel(activeId);\n                    const ancestors = getModelAncestors(folders, workspaces, currentModel);\n                    jotaiStore.set(collapsedFamily(treeId), (prev) => {\n                      const n = { ...prev };\n                      for (const ancestor of ancestors) {\n                        if (ancestor.model === \"folder\") {\n                          delete n[ancestor.id];\n                        }\n                      }\n                      return n;\n                    });\n                    treeRef.current?.selectItem(activeId, false);\n                    treeRef.current?.focus();\n                  },\n                },\n                {\n                  label: \"Expand All Folders\",\n                  leftSlot: <Icon icon=\"chevrons_up_down\" />,\n                  onSelect: actions[\"sidebar.expand_all\"].cb,\n                  hotKeyAction: \"sidebar.expand_all\",\n                  hotKeyLabelOnly: true,\n                },\n                {\n                  label: \"Collapse All Folders\",\n                  leftSlot: <Icon icon=\"chevrons_down_up\" />,\n                  onSelect: actions[\"sidebar.collapse_all\"].cb,\n                  hotKeyAction: \"sidebar.collapse_all\",\n                  hotKeyLabelOnly: true,\n                },\n              ]}\n            >\n              <IconButton\n                size=\"xs\"\n                className=\"ml-0.5 text-text-subtle hover:text-text\"\n                icon=\"ellipsis_vertical\"\n                title=\"Show sidebar actions menu\"\n              />\n            </Dropdown>\n          </>\n        )}\n      </div>\n      {allHidden ? (\n        <div className=\"italic text-text-subtle p-3 text-sm text-center\">\n          No results for <InlineCode>{filterText.text}</InlineCode>\n        </div>\n      ) : (\n        <Tree\n          ref={handleTreeRefInit}\n          root={tree}\n          treeId={treeId}\n          hotkeys={hotkeys}\n          getItemKey={getItemKey}\n          ItemInner={SidebarInnerItem}\n          ItemLeftSlotInner={SidebarLeftSlot}\n          getContextMenu={getContextMenu}\n          onActivate={handleActivate}\n          getEditOptions={getEditOptions}\n          className=\"pl-2 pr-3 pt-2 pb-2\"\n          onDragEnd={handleDragEnd}\n        />\n      )}\n      <GitDropdown />\n    </aside>\n  );\n}\n\nexport default Sidebar;\n\nconst activeIdAtom = atom<string | null>((get) => {\n  return get(activeRequestIdAtom) || get(activeFolderIdAtom);\n});\n\nfunction getEditOptions(\n  item: SidebarModel,\n): ReturnType<NonNullable<TreeItemProps<SidebarModel>[\"getEditOptions\"]>> {\n  return {\n    onChange: handleSubmitEdit,\n    defaultValue: resolvedModelName(item),\n    placeholder: item.name,\n  };\n}\n\nasync function handleSubmitEdit(item: SidebarModel, text: string) {\n  await patchModel(item, { name: text });\n}\n\nfunction handleActivate(item: SidebarModel) {\n  // TODO: Add folder layout support\n  if (item.model !== \"folder\" && item.model !== \"workspace\") {\n    navigateToRequestOrFolderOrWorkspace(item.id, item.model);\n  }\n}\n\nconst allPotentialChildrenAtom = atom<SidebarModel[]>((get) => {\n  const requests = get(allRequestsAtom);\n  const folders = get(foldersAtom);\n  return [...requests, ...folders];\n});\n\nconst memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom);\n\nconst sidebarFilterAtom = atom<{ text: string; key: string }>({\n  text: \"\",\n  key: \"\",\n});\n\nconst sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => {\n  const allModels = get(memoAllPotentialChildrenAtom);\n  const activeWorkspace = get(activeWorkspaceAtom);\n  const filter = get(sidebarFilterAtom);\n\n  const childrenMap: Record<string, Exclude<SidebarModel, Workspace>[]> = {};\n  for (const item of allModels) {\n    if (\"folderId\" in item && item.folderId == null) {\n      childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];\n      childrenMap[item.workspaceId]?.push(item);\n    } else if (\"folderId\" in item && item.folderId != null) {\n      childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];\n      childrenMap[item.folderId]?.push(item);\n    }\n  }\n\n  if (activeWorkspace == null) {\n    return null;\n  }\n\n  const queryAst = parseQuery(filter.text);\n\n  // returns true if this node OR any child matches the filter\n  const allFields: Record<string, Set<string>> = {};\n  const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {\n    const childItems = childrenMap[node.item.id] ?? [];\n    let matchesSelf = true;\n    const fields = getItemFields(node);\n    const model = node.item.model;\n    const isLeafNode = !(model === \"folder\" || model === \"workspace\");\n\n    for (const [field, value] of Object.entries(fields)) {\n      if (!value) continue;\n      allFields[field] = allFields[field] ?? new Set();\n      allFields[field].add(value);\n    }\n\n    if (queryAst != null) {\n      matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields });\n    }\n\n    let matchesChild = false;\n\n    // Recurse to children\n    node.children = !isLeafNode ? [] : undefined;\n\n    if (node.children != null) {\n      childItems.sort((a, b) => {\n        if (a.sortPriority === b.sortPriority) {\n          return a.updatedAt > b.updatedAt ? 1 : -1;\n        }\n        return a.sortPriority - b.sortPriority;\n      });\n\n      for (const item of childItems) {\n        const childNode = { item, parent: node, depth };\n        const childMatches = build(childNode, depth + 1);\n        if (childMatches) {\n          matchesChild = true;\n        }\n        node.children.push(childNode);\n      }\n    }\n\n    // hide node IFF nothing in its subtree matches\n    const anyMatch = matchesSelf || matchesChild;\n    node.hidden = !anyMatch;\n\n    return anyMatch;\n  };\n\n  const root: TreeNode<SidebarModel> = {\n    item: activeWorkspace,\n    parent: null,\n    children: [],\n    depth: 0,\n  };\n\n  // Build tree and mark visibility in one pass\n  build(root, 1);\n\n  const fields: FieldDef[] = [];\n  for (const [name, values] of Object.entries(allFields)) {\n    fields.push({\n      name,\n      values: Array.from(values).filter((v) => v.length < 20),\n    });\n  }\n  return [root, fields] as const;\n});\n\nfunction getItemKey(item: SidebarModel) {\n  const responses = jotaiStore.get(httpResponsesAtom);\n  const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;\n  const url = \"url\" in item ? item.url : \"n/a\";\n  const method = \"method\" in item ? item.method : \"n/a\";\n  const service = \"service\" in item ? item.service : \"n/a\";\n  return [\n    item.id,\n    item.name,\n    url,\n    method,\n    service,\n    latestResponse?.elapsed,\n    latestResponse?.id ?? \"n/a\",\n  ].join(\"::\");\n}\n\nconst SidebarLeftSlot = memo(function SidebarLeftSlot({\n  treeId,\n  item,\n}: {\n  treeId: string;\n  item: SidebarModel;\n}) {\n  if (item.model === \"folder\") {\n    return <Icon icon=\"folder\" />;\n  }\n  if (item.model === \"workspace\") {\n    return null;\n  }\n  const isSelected = jotaiStore.get(isSelectedFamily({ treeId, itemId: item.id }));\n  return (\n    <HttpMethodTag\n      short\n      className={classNames(\"text-xs pl-1.5\", !isSelected && OPACITY_SUBTLE)}\n      request={item}\n    />\n  );\n});\n\nconst SidebarInnerItem = memo(function SidebarInnerItem({\n  item,\n}: {\n  treeId: string;\n  item: SidebarModel;\n}) {\n  const response = useAtomValue(\n    useMemo(\n      () =>\n        selectAtom(\n          atom((get) => [\n            ...get(grpcConnectionsAtom),\n            ...get(httpResponsesAtom),\n            ...get(websocketConnectionsAtom),\n          ]),\n          (responses) => responses.find((r) => r.requestId === item.id),\n          (a, b) => a?.state === b?.state && a?.id === b?.id, // Only update when the response state changes updated\n        ),\n      [item.id],\n    ),\n  );\n\n  return (\n    <div className=\"flex items-center gap-2 min-w-0 h-full w-full text-left\">\n      <div className=\"truncate\">{resolvedModelName(item)}</div>\n      {response != null && (\n        <div className=\"ml-auto\">\n          {response.state !== \"closed\" ? (\n            <LoadingIcon size=\"sm\" className=\"text-text-subtlest\" />\n          ) : response.model === \"http_response\" ? (\n            <HttpStatusTag short className=\"text-xs\" response={response} />\n          ) : null}\n        </div>\n      )}\n    </div>\n  );\n});\n\nfunction getItemFields(node: TreeNode<SidebarModel>): Record<string, string> {\n  const item = node.item;\n\n  if (item.model === \"workspace\") return {};\n\n  const fields: Record<string, string> = {};\n  if (item.model === \"http_request\") {\n    fields.method = item.method.toUpperCase();\n  }\n\n  if (item.model === \"grpc_request\") {\n    fields.grpc_method = item.method ?? \"\";\n    fields.grpc_service = item.service ?? \"\";\n  }\n\n  if (\"url\" in item) fields.url = item.url;\n  fields.name = resolvedModelName(item);\n\n  fields.type = \"http\";\n  if (item.model === \"grpc_request\") fields.type = \"grpc\";\n  else if (item.model === \"websocket_request\") fields.type = \"ws\";\n\n  if (node.parent?.item.model === \"folder\") {\n    fields.folder = node.parent.item.name;\n  }\n\n  return fields;\n}\n\nfunction getItemText(item: SidebarModel): string {\n  const segments = [];\n  if (item.model === \"http_request\") {\n    segments.push(item.method);\n  }\n\n  segments.push(resolvedModelName(item));\n\n  return segments.join(\" \");\n}\n"
  },
  {
    "path": "src-web/components/SidebarActions.tsx",
    "content": "import { useMemo } from \"react\";\nimport { useFloatingSidebarHidden } from \"../hooks/useFloatingSidebarHidden\";\nimport { useShouldFloatSidebar } from \"../hooks/useShouldFloatSidebar\";\nimport { useSidebarHidden } from \"../hooks/useSidebarHidden\";\nimport { CreateDropdown } from \"./CreateDropdown\";\nimport { IconButton } from \"./core/IconButton\";\nimport { HStack } from \"./core/Stacks\";\n\nexport function SidebarActions() {\n  const floating = useShouldFloatSidebar();\n  const [normalHidden, setNormalHidden] = useSidebarHidden();\n  const [floatingHidden, setFloatingHidden] = useFloatingSidebarHidden();\n\n  const hidden = floating ? floatingHidden : normalHidden;\n  const setHidden = useMemo(\n    () => (floating ? setFloatingHidden : setNormalHidden),\n    [floating, setFloatingHidden, setNormalHidden],\n  );\n\n  return (\n    <HStack className=\"h-full\">\n      <IconButton\n        onClick={async () => {\n          // NOTE: We're not using the (h) => !h pattern here because the data\n          //  might be different if another window changed it (out of sync)\n          await setHidden(!hidden);\n        }}\n        className=\"pointer-events-auto\"\n        size=\"sm\"\n        title=\"Toggle sidebar\"\n        icon={hidden ? \"left_panel_hidden\" : \"left_panel_visible\"}\n        iconColor=\"secondary\"\n      />\n      <CreateDropdown hotKeyAction=\"model.create\">\n        <IconButton size=\"sm\" icon=\"plus_circle\" iconColor=\"secondary\" title=\"Add Resource\" />\n      </CreateDropdown>\n    </HStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/SwitchWorkspaceDialog.tsx",
    "content": "import type { Workspace } from \"@yaakapp-internal/models\";\nimport { patchModel, settingsAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useState } from \"react\";\nimport { switchWorkspace } from \"../commands/switchWorkspace\";\nimport { Button } from \"./core/Button\";\nimport { Checkbox } from \"./core/Checkbox\";\nimport { Icon } from \"./core/Icon\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport { HStack, VStack } from \"./core/Stacks\";\n\ninterface Props {\n  hide: () => void;\n  workspace: Workspace;\n}\n\nexport function SwitchWorkspaceDialog({ hide, workspace }: Props) {\n  const settings = useAtomValue(settingsAtom);\n  const [remember, setRemember] = useState<boolean>(false);\n\n  return (\n    <VStack space={3}>\n      <p>\n        Where would you like to open <InlineCode>{workspace.name}</InlineCode>?\n      </p>\n      <HStack space={2} justifyContent=\"start\" className=\"flex-row-reverse\">\n        <Button\n          className=\"focus\"\n          color=\"primary\"\n          onClick={async () => {\n            hide();\n            switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });\n            if (remember) {\n              await patchModel(settings, { openWorkspaceNewWindow: false });\n            }\n          }}\n        >\n          This Window\n        </Button>\n        <Button\n          className=\"focus\"\n          color=\"secondary\"\n          rightSlot={<Icon icon=\"external_link\" />}\n          onClick={async () => {\n            hide();\n            switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });\n            if (remember) {\n              await patchModel(settings, { openWorkspaceNewWindow: true });\n            }\n          }}\n        >\n          New Window\n        </Button>\n      </HStack>\n      {settings && (\n        <HStack justifyContent=\"end\">\n          <Checkbox checked={remember} title=\"Remember my choice\" onChange={setRemember} />\n        </HStack>\n      )}\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/SyncToFilesystemSetting.tsx",
    "content": "import { readDir } from \"@tauri-apps/plugin-fs\";\nimport { useState } from \"react\";\nimport { openWorkspaceFromSyncDir } from \"../commands/openWorkspaceFromSyncDir\";\nimport { Banner } from \"./core/Banner\";\nimport { Button } from \"./core/Button\";\nimport { Checkbox } from \"./core/Checkbox\";\nimport { VStack } from \"./core/Stacks\";\nimport { SelectFile } from \"./SelectFile\";\n\nexport interface SyncToFilesystemSettingProps {\n  onChange: (args: { filePath: string | null; initGit?: boolean }) => void;\n  onCreateNewWorkspace: () => void;\n  value: { filePath: string | null; initGit?: boolean };\n}\n\nexport function SyncToFilesystemSetting({\n  onChange,\n  onCreateNewWorkspace,\n  value,\n}: SyncToFilesystemSettingProps) {\n  const [syncDir, setSyncDir] = useState<string | null>(null);\n  return (\n    <VStack className=\"w-full my-2\" space={3}>\n      {syncDir && (\n        <Banner color=\"notice\" className=\"flex flex-col gap-1.5\">\n          <p>Directory is not empty. Do you want to open it instead?</p>\n          <div>\n            <Button\n              variant=\"border\"\n              color=\"notice\"\n              size=\"xs\"\n              type=\"button\"\n              onClick={() => {\n                openWorkspaceFromSyncDir.mutate(syncDir);\n                onCreateNewWorkspace();\n              }}\n            >\n              Open Workspace\n            </Button>\n          </div>\n        </Banner>\n      )}\n\n      <SelectFile\n        directory\n        label=\"Local directory sync\"\n        size=\"xs\"\n        noun=\"Directory\"\n        help=\"Sync data to a folder for backup and Git integration.\"\n        filePath={value.filePath}\n        onChange={async ({ filePath }) => {\n          if (filePath != null) {\n            const files = await readDir(filePath);\n            if (files.length > 0) {\n              setSyncDir(filePath);\n              return;\n            }\n          }\n\n          setSyncDir(null);\n          onChange({ ...value, filePath });\n        }}\n      />\n\n      {value.filePath && typeof value.initGit === \"boolean\" && (\n        <Checkbox\n          checked={value.initGit}\n          onChange={(initGit) => onChange({ ...value, initGit })}\n          title=\"Initialize Git Repo\"\n        />\n      )}\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/TemplateFunctionDialog.tsx",
    "content": "import type { EditorView } from \"@codemirror/view\";\nimport type {\n  Folder,\n  GrpcRequest,\n  HttpRequest,\n  WebsocketRequest,\n  Workspace,\n} from \"@yaakapp-internal/models\";\nimport type { TemplateFunction } from \"@yaakapp-internal/plugins\";\nimport type { FnArg, Tokens } from \"@yaakapp-internal/templates\";\nimport { parseTemplate } from \"@yaakapp-internal/templates\";\nimport classNames from \"classnames\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { activeWorkspaceAtom } from \"../hooks/useActiveWorkspace\";\nimport { useDebouncedValue } from \"../hooks/useDebouncedValue\";\nimport { useRenderTemplate } from \"../hooks/useRenderTemplate\";\nimport { useTemplateFunctionConfig } from \"../hooks/useTemplateFunctionConfig\";\nimport {\n  templateTokensToString,\n  useTemplateTokensToString,\n} from \"../hooks/useTemplateTokensToString\";\nimport { useToggle } from \"../hooks/useToggle\";\nimport { showDialog } from \"../lib/dialog\";\nimport { convertTemplateToInsecure } from \"../lib/encryption\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { setupOrConfigureEncryption } from \"../lib/setupOrConfigureEncryption\";\nimport { Button } from \"./core/Button\";\nimport { collectArgumentValues } from \"./core/Editor/twig/util\";\nimport { IconButton } from \"./core/IconButton\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport { LoadingIcon } from \"./core/LoadingIcon\";\nimport { PlainInput } from \"./core/PlainInput\";\nimport { HStack } from \"./core/Stacks\";\nimport { DYNAMIC_FORM_NULL_ARG, DynamicForm } from \"./DynamicForm\";\n\ninterface Props {\n  templateFunction: TemplateFunction;\n  initialTokens: Tokens;\n  hide: () => void;\n  onChange: (insert: string) => void;\n  model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;\n}\n\nexport function TemplateFunctionDialog({ initialTokens, templateFunction, ...props }: Props) {\n  const [initialArgValues, setInitialArgValues] = useState<Record<string, string | boolean> | null>(\n    null,\n  );\n  useEffect(() => {\n    if (initialArgValues != null) {\n      return;\n    }\n\n    (async () => {\n      const initial = collectArgumentValues(initialTokens, templateFunction);\n\n      // HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so\n      //  we can display it in the editor input.\n      if (templateFunction.name === \"secure\") {\n        const template = await templateTokensToString(initialTokens);\n        initial.value = await convertTemplateToInsecure(template);\n      }\n\n      setInitialArgValues(initial);\n    })().catch(console.error);\n  }, [\n    initialArgValues,\n    initialTokens,\n    initialTokens.tokens,\n    templateFunction,\n    templateFunction.args,\n    templateFunction.name,\n  ]);\n\n  if (initialArgValues == null) return null;\n\n  return (\n    <InitializedTemplateFunctionDialog\n      {...props}\n      templateFunction={templateFunction}\n      initialArgValues={initialArgValues}\n    />\n  );\n}\n\nfunction InitializedTemplateFunctionDialog({\n  templateFunction: { name, previewType: ogPreviewType },\n  initialArgValues,\n  hide,\n  onChange,\n  model,\n}: Omit<Props, \"initialTokens\"> & {\n  initialArgValues: Record<string, string | boolean>;\n}) {\n  const previewType = ogPreviewType == null ? \"live\" : ogPreviewType;\n  const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);\n  const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);\n\n  const tokens: Tokens = useMemo(() => {\n    const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({\n      name,\n      value:\n        argValues[name] === DYNAMIC_FORM_NULL_ARG\n          ? { type: \"null\" }\n          : typeof argValues[name] === \"boolean\"\n            ? { type: \"bool\", value: argValues[name] === true }\n            : { type: \"str\", text: String(argValues[name] ?? \"\") },\n    }));\n\n    return {\n      tokens: [\n        {\n          type: \"tag\",\n          val: {\n            type: \"fn\",\n            name,\n            args: argTokens,\n          },\n        },\n      ],\n    };\n  }, [argValues, name]);\n\n  const tagText = useTemplateTokensToString(tokens);\n  const templateFunction = useTemplateFunctionConfig(name, argValues, model);\n\n  const handleDone = () => {\n    if (tagText.data) {\n      onChange(tagText.data);\n    }\n    hide();\n  };\n\n  const debouncedTagText = useDebouncedValue(tagText.data ?? \"\", 400);\n  const [renderKey, setRenderKey] = useState<string | null>(null);\n  const rendered = useRenderTemplate({\n    template: debouncedTagText,\n    enabled: previewType !== \"none\",\n    purpose: previewType === \"click\" ? \"send\" : \"preview\",\n    refreshKey: previewType === \"live\" ? renderKey + debouncedTagText : renderKey,\n    ignoreError: false,\n  });\n\n  const tooLarge = rendered.data ? rendered.data.length > 10000 : false;\n  // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only update this on rendered data change to keep secrets hidden on input change\n  const dataContainsSecrets = useMemo(() => {\n    for (const [name, value] of Object.entries(argValues)) {\n      const arg = templateFunction.data?.args.find((a) => \"name\" in a && a.name === name);\n      const isTextPassword = arg?.type === \"text\" && arg.password;\n      if (isTextPassword && typeof value === \"string\" && value && rendered.data?.includes(value)) {\n        return true;\n      }\n    }\n    return false;\n  }, [rendered.data]);\n\n  if (templateFunction.data == null || templateFunction.isPending) {\n    return (\n      <div className=\"h-full w-full flex items-center justify-center\">\n        <LoadingIcon size=\"xl\" className=\"text-text-subtlest\" />\n      </div>\n    );\n  }\n\n  return (\n    <form\n      className=\"grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]\"\n      onSubmit={(e) => {\n        e.preventDefault();\n        handleDone();\n      }}\n    >\n      <div className=\"overflow-y-auto h-full px-6\">\n        {name === \"secure\" ? (\n          <PlainInput\n            required\n            label=\"Value\"\n            name=\"value\"\n            type=\"password\"\n            placeholder=\"••••••••••••\"\n            defaultValue={String(argValues.value ?? \"\")}\n            onChange={(value) => setArgValues({ ...argValues, value })}\n          />\n        ) : (\n          <DynamicForm\n            autocompleteVariables\n            autocompleteFunctions\n            inputs={templateFunction.data.args}\n            data={argValues}\n            onChange={setArgValues}\n            stateKey={`template_function.${templateFunction.data.name}`}\n          />\n        )}\n      </div>\n      <div className=\"px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4\">\n        {previewType !== \"none\" ? (\n          <div className=\"w-full grid grid-cols-1 grid-rows-[auto_auto]\">\n            <HStack space={0.5}>\n              <HStack className=\"text-sm text-text-subtle\" space={1.5}>\n                Rendered Preview\n                {rendered.isLoading && <LoadingIcon size=\"xs\" />}\n              </HStack>\n              <IconButton\n                size=\"xs\"\n                iconSize=\"sm\"\n                icon={showSecretsInPreview ? \"lock\" : \"lock_open\"}\n                title={showSecretsInPreview ? \"Show preview\" : \"Hide preview\"}\n                onClick={toggleShowSecretsInPreview}\n                className={classNames(\n                  \"ml-auto text-text-subtlest\",\n                  !dataContainsSecrets && \"invisible\",\n                )}\n              />\n            </HStack>\n            <div className=\"relative w-full max-h-[10rem]\">\n              <InlineCode\n                className={classNames(\n                  \"block whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-auto hide-scrollbars !border-text-subtlest\",\n                  tooLarge && \"italic text-danger\",\n                )}\n              >\n                {rendered.error || tagText.error ? (\n                  <em className=\"text-danger\">\n                    {`${rendered.error || tagText.error}`.replace(/^Render Error: /, \"\")}\n                  </em>\n                ) : dataContainsSecrets && !showSecretsInPreview ? (\n                  <span className=\"italic text-text-subtle\">\n                    ------ sensitive values hidden ------\n                  </span>\n                ) : tooLarge ? (\n                  \"too large to preview\"\n                ) : (\n                  rendered.data || <>&nbsp;</>\n                )}\n              </InlineCode>\n              <div className=\"absolute right-0.5 top-0 bottom-0 flex items-center\">\n                <IconButton\n                  size=\"xs\"\n                  icon=\"refresh\"\n                  className=\"text-text-subtle\"\n                  title=\"Refresh preview\"\n                  spin={rendered.isPending}\n                  onClick={() => {\n                    setRenderKey(new Date().toISOString());\n                  }}\n                />\n              </div>\n            </div>\n          </div>\n        ) : (\n          <span />\n        )}\n        <div className=\"flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1\">\n          {templateFunction.data.name === \"secure\" && (\n            <Button variant=\"border\" color=\"secondary\" onClick={setupOrConfigureEncryption}>\n              Reveal Encryption Key\n            </Button>\n          )}\n          <Button type=\"submit\" color=\"primary\">\n            Save\n          </Button>\n        </div>\n      </div>\n    </form>\n  );\n}\n\nTemplateFunctionDialog.show = (\n  fn: TemplateFunction,\n  tagValue: string,\n  startPos: number,\n  view: EditorView,\n) => {\n  const initialTokens = parseTemplate(tagValue);\n  showDialog({\n    id: `template-function-${Math.random()}`, // Allow multiple at once\n    size: \"md\",\n    className: \"h-[60rem]\",\n    noPadding: true,\n    title: <InlineCode>{fn.name}(…)</InlineCode>,\n    description: fn.description,\n    render: ({ hide }) => {\n      const model = jotaiStore.get(activeWorkspaceAtom);\n      if (model == null) return null;\n      return (\n        <TemplateFunctionDialog\n          templateFunction={fn}\n          model={model}\n          hide={hide}\n          initialTokens={initialTokens}\n          onChange={(insert) => {\n            view.dispatch({\n              changes: [{ from: startPos, to: startPos + tagValue.length, insert }],\n            });\n          }}\n        />\n      );\n    },\n  });\n};\n"
  },
  {
    "path": "src-web/components/Toasts.tsx",
    "content": "import { useAtomValue } from \"jotai\";\nimport { AnimatePresence } from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { hideToast, toastsAtom } from \"../lib/toast\";\nimport { Toast, type ToastProps } from \"./core/Toast\";\nimport { ErrorBoundary } from \"./ErrorBoundary\";\nimport { Portal } from \"./Portal\";\n\nexport type ToastInstance = {\n  id: string;\n  uniqueKey: string;\n  message: ReactNode;\n  timeout: 3000 | 5000 | 8000 | (number & {}) | null;\n  onClose?: ToastProps[\"onClose\"];\n} & Omit<ToastProps, \"onClose\" | \"open\" | \"children\" | \"timeout\">;\n\nexport const Toasts = () => {\n  const toasts = useAtomValue(toastsAtom);\n  return (\n    <Portal name=\"toasts\">\n      <div className=\"absolute right-0 bottom-0 z-50\">\n        <AnimatePresence>\n          {toasts.map((toast: ToastInstance) => {\n            const { message, uniqueKey, ...props } = toast;\n            return (\n              <ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>\n                <Toast\n                  open\n                  {...props}\n                  // We call onClose inside actions.hide instead of passing to toast so that\n                  // it gets called from external close calls as well\n                  onClose={() => hideToast(toast)}\n                >\n                  {message}\n                </Toast>\n              </ErrorBoundary>\n            );\n          })}\n        </AnimatePresence>\n      </div>\n    </Portal>\n  );\n};\n"
  },
  {
    "path": "src-web/components/UrlBar.tsx",
    "content": "import type { HttpRequest } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport type { FormEvent, ReactNode } from \"react\";\nimport { memo, useCallback, useRef, useState } from \"react\";\nimport { useHotKey } from \"../hooks/useHotKey\";\nimport type { IconProps } from \"./core/Icon\";\nimport { IconButton } from \"./core/IconButton\";\nimport type { InputHandle, InputProps } from \"./core/Input\";\nimport { Input } from \"./core/Input\";\nimport { HStack } from \"./core/Stacks\";\n\ntype Props = Pick<HttpRequest, \"url\"> & {\n  className?: string;\n  placeholder: string;\n  onSend: () => void;\n  onUrlChange: (url: string) => void;\n  onPaste?: (v: string) => void;\n  onPasteOverwrite?: InputProps[\"onPasteOverwrite\"];\n  onCancel: () => void;\n  submitIcon?: IconProps[\"icon\"] | null;\n  isLoading: boolean;\n  forceUpdateKey: string;\n  rightSlot?: ReactNode;\n  leftSlot?: ReactNode;\n  autocomplete?: InputProps[\"autocomplete\"];\n  stateKey: InputProps[\"stateKey\"];\n};\n\nexport const UrlBar = memo(function UrlBar({\n  forceUpdateKey,\n  onUrlChange,\n  url,\n  placeholder,\n  className,\n  onSend,\n  onCancel,\n  onPaste,\n  onPasteOverwrite,\n  submitIcon = \"send_horizontal\",\n  autocomplete,\n  leftSlot,\n  rightSlot,\n  isLoading,\n  stateKey,\n}: Props) {\n  const inputRef = useRef<InputHandle>(null);\n  const [isFocused, setIsFocused] = useState<boolean>(false);\n\n  const handleInitInputRef = useCallback((h: InputHandle | null) => {\n    inputRef.current = h;\n  }, []);\n\n  useHotKey(\"url_bar.focus\", () => {\n    inputRef.current?.selectAll();\n  });\n\n  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    if (isLoading) onCancel();\n    else onSend();\n  };\n\n  return (\n    <form onSubmit={handleSubmit} className={classNames(\"x-theme-urlBar\", className)}>\n      <Input\n        setRef={handleInitInputRef}\n        autocompleteFunctions\n        autocompleteVariables\n        stateKey={stateKey}\n        size=\"sm\"\n        wrapLines={isFocused}\n        hideLabel\n        language=\"url\"\n        className=\"px-1.5 py-0.5\"\n        label=\"Enter URL\"\n        name=\"url\"\n        autocomplete={autocomplete}\n        forceUpdateKey={forceUpdateKey}\n        onFocus={() => setIsFocused(true)}\n        onBlur={() => setIsFocused(false)}\n        onPaste={onPaste}\n        onPasteOverwrite={onPasteOverwrite}\n        onChange={onUrlChange}\n        defaultValue={url}\n        placeholder={placeholder}\n        leftSlot={leftSlot}\n        rightSlot={\n          <HStack space={0.5}>\n            {rightSlot && <div className=\"py-0.5 h-full\">{rightSlot}</div>}\n            {submitIcon !== null && (\n              <div className=\"py-0.5 h-full\">\n                <IconButton\n                  size=\"xs\"\n                  iconSize=\"md\"\n                  title=\"Send Request\"\n                  type=\"submit\"\n                  className=\"w-8 mr-0.5 !h-full\"\n                  iconColor=\"secondary\"\n                  icon={isLoading ? \"x\" : submitIcon}\n                  hotkeyAction=\"request.send\"\n                  onMouseDown={(e) => {\n                    // Prevent the button from taking focus\n                    e.preventDefault();\n                  }}\n                />\n              </div>\n            )}\n          </HStack>\n        }\n      />\n    </form>\n  );\n});\n"
  },
  {
    "path": "src-web/components/UrlParameterEditor.tsx",
    "content": "import type { HttpRequest } from \"@yaakapp-internal/models\";\nimport { useCallback, useRef } from \"react\";\nimport { useRequestEditor, useRequestEditorEvent } from \"../hooks/useRequestEditor\";\nimport type { PairEditorHandle, PairEditorProps } from \"./core/PairEditor\";\nimport { PairOrBulkEditor } from \"./core/PairOrBulkEditor\";\nimport { VStack } from \"./core/Stacks\";\n\ntype Props = {\n  forceUpdateKey: string;\n  pairs: HttpRequest[\"headers\"];\n  stateKey: PairEditorProps[\"stateKey\"];\n  onChange: (headers: HttpRequest[\"urlParameters\"]) => void;\n};\n\nexport function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {\n  const pairEditorRef = useRef<PairEditorHandle>(null);\n  const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => {\n    pairEditorRef.current = ref;\n  }, []);\n\n  const [{ urlParametersKey }] = useRequestEditor();\n\n  useRequestEditorEvent(\n    \"request_params.focus_value\",\n    (name) => {\n      const pair = pairs.find((p) => p.name === name);\n      if (pair?.id != null) {\n        pairEditorRef.current?.focusValue(pair.id);\n      } else {\n        console.log(`Couldn't find pair to focus`, { name, pairs });\n      }\n    },\n    [pairs],\n  );\n\n  return (\n    <VStack className=\"h-full\">\n      <PairOrBulkEditor\n        setRef={handleInitPairEditorRef}\n        allowMultilineValues\n        forceUpdateKey={forceUpdateKey + urlParametersKey}\n        nameAutocompleteFunctions\n        nameAutocompleteVariables\n        namePlaceholder=\"param_name\"\n        onChange={onChange}\n        pairs={pairs}\n        preferenceName=\"url_parameters\"\n        stateKey={stateKey}\n        valueAutocompleteFunctions\n        valueAutocompleteVariables\n        valuePlaceholder=\"Value\"\n      />\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/WebsocketRequestLayout.tsx",
    "content": "import type { WebsocketRequest } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport type { CSSProperties } from \"react\";\n\nimport { workspaceLayoutAtom } from \"../lib/atoms\";\nimport { SplitLayout } from \"./core/SplitLayout\";\nimport { WebsocketRequestPane } from \"./WebsocketRequestPane\";\nimport { WebsocketResponsePane } from \"./WebsocketResponsePane\";\n\ninterface Props {\n  activeRequest: WebsocketRequest;\n  style: CSSProperties;\n}\n\nexport function WebsocketRequestLayout({ activeRequest, style }: Props) {\n  const workspaceLayout = useAtomValue(workspaceLayoutAtom);\n  return (\n    <SplitLayout\n      name=\"websocket_layout\"\n      className=\"p-3 gap-1.5\"\n      layout={workspaceLayout}\n      style={style}\n      firstSlot={({ orientation, style }) => (\n        <WebsocketRequestPane\n          style={style}\n          activeRequest={activeRequest}\n          fullHeight={orientation === \"horizontal\"}\n        />\n      )}\n      secondSlot={({ style }) => (\n        <div\n          style={style}\n          className={classNames(\n            \"x-theme-responsePane\",\n            \"max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1\",\n            \"bg-surface rounded-md border border-border-subtle\",\n            \"shadow relative\",\n          )}\n        >\n          <WebsocketResponsePane activeRequest={activeRequest} />\n        </div>\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/WebsocketRequestPane.tsx",
    "content": "import type { WebsocketRequest } from \"@yaakapp-internal/models\";\nimport { patchModel } from \"@yaakapp-internal/models\";\nimport type { GenericCompletionOption } from \"@yaakapp-internal/plugins\";\nimport { closeWebsocket, connectWebsocket, sendWebsocket } from \"@yaakapp-internal/ws\";\nimport classNames from \"classnames\";\nimport { atom, useAtomValue } from \"jotai\";\nimport type { CSSProperties } from \"react\";\nimport { useCallback, useMemo, useRef } from \"react\";\nimport { getActiveCookieJar } from \"../hooks/useActiveCookieJar\";\nimport { getActiveEnvironment } from \"../hooks/useActiveEnvironment\";\nimport { activeRequestIdAtom } from \"../hooks/useActiveRequestId\";\nimport { allRequestsAtom } from \"../hooks/useAllRequests\";\nimport { useAuthTab } from \"../hooks/useAuthTab\";\nimport { useCancelHttpResponse } from \"../hooks/useCancelHttpResponse\";\nimport { useHeadersTab } from \"../hooks/useHeadersTab\";\nimport { useInheritedHeaders } from \"../hooks/useInheritedHeaders\";\nimport { usePinnedHttpResponse } from \"../hooks/usePinnedHttpResponse\";\nimport { activeWebsocketConnectionAtom } from \"../hooks/usePinnedWebsocketConnection\";\nimport { useRequestEditor, useRequestEditorEvent } from \"../hooks/useRequestEditor\";\nimport { useRequestUpdateKey } from \"../hooks/useRequestUpdateKey\";\nimport { deepEqualAtom } from \"../lib/atoms\";\nimport { languageFromContentType } from \"../lib/contentType\";\nimport { generateId } from \"../lib/generateId\";\nimport { prepareImportQuerystring } from \"../lib/prepareImportQuerystring\";\nimport { resolvedModelName } from \"../lib/resolvedModelName\";\nimport { CountBadge } from \"./core/CountBadge\";\nimport type { GenericCompletionConfig } from \"./core/Editor/genericCompletion\";\nimport { Editor } from \"./core/Editor/LazyEditor\";\nimport { IconButton } from \"./core/IconButton\";\nimport type { Pair } from \"./core/PairEditor\";\nimport { PlainInput } from \"./core/PlainInput\";\nimport type { TabItem, TabsRef } from \"./core/Tabs/Tabs\";\nimport { setActiveTab, TabContent, Tabs } from \"./core/Tabs/Tabs\";\nimport { HeadersEditor } from \"./HeadersEditor\";\nimport { HttpAuthenticationEditor } from \"./HttpAuthenticationEditor\";\nimport { MarkdownEditor } from \"./MarkdownEditor\";\nimport { UrlBar } from \"./UrlBar\";\nimport { UrlParametersEditor } from \"./UrlParameterEditor\";\n\ninterface Props {\n  style: CSSProperties;\n  fullHeight: boolean;\n  className?: string;\n  activeRequest: WebsocketRequest;\n}\n\nconst TAB_MESSAGE = \"message\";\nconst TAB_PARAMS = \"params\";\nconst TAB_HEADERS = \"headers\";\nconst TAB_AUTH = \"auth\";\nconst TAB_DESCRIPTION = \"description\";\nconst TABS_STORAGE_KEY = \"websocket_request_tabs\";\n\nconst nonActiveRequestUrlsAtom = atom((get) => {\n  const activeRequestId = get(activeRequestIdAtom);\n  const requests = get(allRequestsAtom);\n  return requests\n    .filter((r) => r.id !== activeRequestId)\n    .map((r): GenericCompletionOption => ({ type: \"constant\", label: r.url }));\n});\n\nconst memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);\n\nexport function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {\n  const activeRequestId = activeRequest.id;\n  const tabsRef = useRef<TabsRef>(null);\n  const forceUpdateKey = useRequestUpdateKey(activeRequest.id);\n  const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();\n  const authTab = useAuthTab(TAB_AUTH, activeRequest);\n  const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);\n  const inheritedHeaders = useInheritedHeaders(activeRequest);\n\n  // Listen for event to focus the params tab (e.g., when clicking a :param in the URL)\n  useRequestEditorEvent(\n    \"request_pane.focus_tab\",\n    () => {\n      tabsRef.current?.setActiveTab(TAB_PARAMS);\n    },\n    [],\n  );\n\n  const { urlParameterPairs, urlParametersKey } = useMemo(() => {\n    const placeholderNames = Array.from(activeRequest.url.matchAll(/\\/(:[^/]+)/g)).map(\n      (m) => m[1] ?? \"\",\n    );\n    const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);\n    const items: Pair[] = [...nonEmptyParameters];\n    for (const name of placeholderNames) {\n      const item = items.find((p) => p.name === name);\n      if (item) {\n        item.readOnlyName = true;\n      } else {\n        items.push({ name, value: \"\", enabled: true, readOnlyName: true, id: generateId() });\n      }\n    }\n    return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(\",\") };\n  }, [activeRequest.url, activeRequest.urlParameters]);\n\n  const tabs = useMemo<TabItem[]>(() => {\n    return [\n      {\n        value: TAB_MESSAGE,\n        label: \"Message\",\n      } as TabItem,\n      {\n        value: TAB_PARAMS,\n        rightSlot: <CountBadge count={urlParameterPairs.length} />,\n        label: \"Params\",\n      },\n      ...headersTab,\n      ...authTab,\n      {\n        value: TAB_DESCRIPTION,\n        label: \"Info\",\n      },\n    ];\n  }, [authTab, headersTab, urlParameterPairs.length]);\n\n  const { activeResponse } = usePinnedHttpResponse(activeRequestId);\n  const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);\n  const connection = useAtomValue(activeWebsocketConnectionAtom);\n\n  const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);\n\n  const autocomplete: GenericCompletionConfig = useMemo(\n    () => ({\n      minMatch: 3,\n      options:\n        autocompleteUrls.length > 0\n          ? autocompleteUrls\n          : [\n              { label: \"http://\", type: \"constant\" },\n              { label: \"https://\", type: \"constant\" },\n            ],\n    }),\n    [autocompleteUrls],\n  );\n\n  const handleConnect = useCallback(async () => {\n    await connectWebsocket({\n      requestId: activeRequest.id,\n      environmentId: getActiveEnvironment()?.id ?? null,\n      cookieJarId: getActiveCookieJar()?.id ?? null,\n    });\n  }, [activeRequest.id]);\n\n  const handleSend = useCallback(async () => {\n    if (connection == null) return;\n    await sendWebsocket({\n      connectionId: connection?.id,\n      environmentId: getActiveEnvironment()?.id ?? null,\n    });\n  }, [connection]);\n\n  const handleCancel = useCallback(async () => {\n    if (connection == null) return;\n    await closeWebsocket({ connectionId: connection?.id });\n  }, [connection]);\n\n  const handleUrlChange = useCallback(\n    (url: string) => patchModel(activeRequest, { url }),\n    [activeRequest],\n  );\n\n  const handlePaste = useCallback(\n    async (e: ClipboardEvent, text: string) => {\n      const patch = prepareImportQuerystring(text);\n      if (patch != null) {\n        e.preventDefault(); // Prevent input onChange\n\n        await patchModel(activeRequest, patch);\n        await setActiveTab({\n          storageKey: TABS_STORAGE_KEY,\n          activeTabKey: activeRequestId,\n          value: TAB_PARAMS,\n        });\n\n        // Wait for request to update, then refresh the UI\n        // TODO: Somehow make this deterministic\n        setTimeout(() => {\n          forceUrlRefresh();\n          forceParamsRefresh();\n        }, 100);\n      }\n    },\n    [activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],\n  );\n\n  const messageLanguage = languageFromContentType(null, activeRequest.message);\n\n  const isLoading = connection !== null && connection.state !== \"closed\";\n\n  return (\n    <div\n      style={style}\n      className={classNames(className, \"h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1\")}\n    >\n      {activeRequest && (\n        <>\n          <div className=\"grid grid-cols-[minmax(0,1fr)_auto]\">\n            <UrlBar\n              stateKey={`url.${activeRequest.id}`}\n              key={forceUpdateKey + urlKey}\n              url={activeRequest.url}\n              submitIcon={isLoading ? \"send_horizontal\" : \"arrow_up_down\"}\n              rightSlot={\n                isLoading && (\n                  <IconButton\n                    size=\"xs\"\n                    title=\"Close connection\"\n                    icon=\"x\"\n                    iconColor=\"secondary\"\n                    className=\"w-8 mr-0.5 !h-full\"\n                    onClick={handleCancel}\n                  />\n                )\n              }\n              placeholder=\"wss://example.com\"\n              onPasteOverwrite={handlePaste}\n              autocomplete={autocomplete}\n              onSend={isLoading ? handleSend : handleConnect}\n              onCancel={cancelResponse}\n              onUrlChange={handleUrlChange}\n              forceUpdateKey={forceUpdateKey}\n              isLoading={activeResponse != null && activeResponse.state !== \"closed\"}\n            />\n          </div>\n          <Tabs\n            ref={tabsRef}\n            label=\"Request\"\n            tabs={tabs}\n            tabListClassName=\"mt-1 !mb-1.5\"\n            storageKey={TABS_STORAGE_KEY}\n            activeTabKey={activeRequestId}\n          >\n            <TabContent value={TAB_AUTH}>\n              <HttpAuthenticationEditor model={activeRequest} />\n            </TabContent>\n            <TabContent value={TAB_HEADERS}>\n              <HeadersEditor\n                inheritedHeaders={inheritedHeaders}\n                forceUpdateKey={forceUpdateKey}\n                headers={activeRequest.headers}\n                stateKey={`headers.${activeRequest.id}`}\n                onChange={(headers) => patchModel(activeRequest, { headers })}\n              />\n            </TabContent>\n            <TabContent value={TAB_PARAMS}>\n              <UrlParametersEditor\n                stateKey={`params.${activeRequest.id}`}\n                forceUpdateKey={forceUpdateKey + urlParametersKey}\n                pairs={urlParameterPairs}\n                onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}\n              />\n            </TabContent>\n            <TabContent value={TAB_MESSAGE}>\n              <Editor\n                forceUpdateKey={forceUpdateKey}\n                autocompleteFunctions\n                autocompleteVariables\n                placeholder=\"...\"\n                heightMode={fullHeight ? \"full\" : \"auto\"}\n                defaultValue={activeRequest.message}\n                language={messageLanguage}\n                onChange={(message) => patchModel(activeRequest, { message })}\n                stateKey={`json.${activeRequest.id}`}\n              />\n            </TabContent>\n            <TabContent value={TAB_DESCRIPTION}>\n              <div className=\"grid grid-rows-[auto_minmax(0,1fr)] h-full\">\n                <PlainInput\n                  label=\"Request Name\"\n                  hideLabel\n                  forceUpdateKey={forceUpdateKey}\n                  defaultValue={activeRequest.name}\n                  className=\"font-sans !text-xl !px-0\"\n                  containerClassName=\"border-0\"\n                  placeholder={resolvedModelName(activeRequest)}\n                  onChange={(name) => patchModel(activeRequest, { name })}\n                />\n                <MarkdownEditor\n                  name=\"request-description\"\n                  placeholder=\"Request description\"\n                  defaultValue={activeRequest.description}\n                  stateKey={`description.${activeRequest.id}`}\n                  forceUpdateKey={forceUpdateKey}\n                  onChange={(description) => patchModel(activeRequest, { description })}\n                />\n              </div>\n            </TabContent>\n          </Tabs>\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/WebsocketResponsePane.tsx",
    "content": "import type { WebsocketEvent, WebsocketRequest } from \"@yaakapp-internal/models\";\nimport { hexy } from \"hexy\";\nimport { useAtomValue } from \"jotai\";\nimport { useMemo, useState } from \"react\";\nimport { useFormatText } from \"../hooks/useFormatText\";\nimport {\n  activeWebsocketConnectionAtom,\n  activeWebsocketConnectionsAtom,\n  setPinnedWebsocketConnectionId,\n  useWebsocketEvents,\n} from \"../hooks/usePinnedWebsocketConnection\";\nimport { useStateWithDeps } from \"../hooks/useStateWithDeps\";\nimport { languageFromContentType } from \"../lib/contentType\";\nimport { Button } from \"./core/Button\";\nimport { Editor } from \"./core/Editor/LazyEditor\";\nimport { type EventDetailAction, EventDetailHeader, EventViewer } from \"./core/EventViewer\";\nimport { EventViewerRow } from \"./core/EventViewerRow\";\nimport { HotkeyList } from \"./core/HotkeyList\";\nimport { Icon } from \"./core/Icon\";\nimport { LoadingIcon } from \"./core/LoadingIcon\";\nimport { HStack, VStack } from \"./core/Stacks\";\nimport { WebsocketStatusTag } from \"./core/WebsocketStatusTag\";\nimport { EmptyStateText } from \"./EmptyStateText\";\nimport { ErrorBoundary } from \"./ErrorBoundary\";\nimport { RecentWebsocketConnectionsDropdown } from \"./RecentWebsocketConnectionsDropdown\";\n\ninterface Props {\n  activeRequest: WebsocketRequest;\n}\n\nexport function WebsocketResponsePane({ activeRequest }: Props) {\n  const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);\n  const [showingLarge, setShowingLarge] = useState<boolean>(false);\n  const [hexDumps, setHexDumps] = useState<Record<number, boolean>>({});\n\n  const activeConnection = useAtomValue(activeWebsocketConnectionAtom);\n  const connections = useAtomValue(activeWebsocketConnectionsAtom);\n  const events = useWebsocketEvents(activeConnection?.id ?? null);\n\n  if (activeConnection == null) {\n    return (\n      <HotkeyList hotkeys={[\"request.send\", \"model.create\", \"sidebar.focus\", \"url_bar.focus\"]} />\n    );\n  }\n\n  const header = (\n    <HStack className=\"pl-3 mb-1 font-mono text-sm text-text-subtle\">\n      <HStack space={2}>\n        {activeConnection.state !== \"closed\" && (\n          <LoadingIcon size=\"sm\" className=\"text-text-subtlest\" />\n        )}\n        <WebsocketStatusTag connection={activeConnection} />\n        <span>&bull;</span>\n        <span>{events.length} Messages</span>\n      </HStack>\n      <HStack space={0.5} className=\"ml-auto\">\n        <RecentWebsocketConnectionsDropdown\n          connections={connections}\n          activeConnection={activeConnection}\n          onPinnedConnectionId={setPinnedWebsocketConnectionId}\n        />\n      </HStack>\n    </HStack>\n  );\n\n  return (\n    <ErrorBoundary name=\"Websocket Events\">\n      <EventViewer\n        events={events}\n        getEventKey={(event) => event.id}\n        error={activeConnection.error}\n        header={header}\n        splitLayoutName=\"websocket_events\"\n        defaultRatio={0.4}\n        renderRow={({ event, isActive, onClick }) => (\n          <WebsocketEventRow event={event} isActive={isActive} onClick={onClick} />\n        )}\n        renderDetail={({ event, index, onClose }) => (\n          <WebsocketEventDetail\n            event={event}\n            hexDump={hexDumps[index] ?? event.messageType === \"binary\"}\n            setHexDump={(v) => setHexDumps({ ...hexDumps, [index]: v })}\n            showLarge={showLarge}\n            showingLarge={showingLarge}\n            setShowLarge={setShowLarge}\n            setShowingLarge={setShowingLarge}\n            onClose={onClose}\n          />\n        )}\n      />\n    </ErrorBoundary>\n  );\n}\n\nfunction WebsocketEventRow({\n  event,\n  isActive,\n  onClick,\n}: {\n  event: WebsocketEvent;\n  isActive: boolean;\n  onClick: () => void;\n}) {\n  const { message: messageBytes, isServer, messageType } = event;\n  const message = messageBytes\n    ? new TextDecoder(\"utf-8\").decode(Uint8Array.from(messageBytes))\n    : \"\";\n\n  const iconColor =\n    messageType === \"close\" || messageType === \"open\" ? \"secondary\" : isServer ? \"info\" : \"primary\";\n\n  const icon =\n    messageType === \"close\" || messageType === \"open\"\n      ? \"info\"\n      : isServer\n        ? \"arrow_big_down_dash\"\n        : \"arrow_big_up_dash\";\n\n  const content =\n    messageType === \"close\" ? (\n      \"Disconnected from server\"\n    ) : messageType === \"open\" ? (\n      \"Connected to server\"\n    ) : message === \"\" ? (\n      <em className=\"italic text-text-subtlest\">No content</em>\n    ) : (\n      <span className=\"text-xs\">{message.slice(0, 1000)}</span>\n    );\n\n  return (\n    <EventViewerRow\n      isActive={isActive}\n      onClick={onClick}\n      icon={<Icon color={iconColor} icon={icon} />}\n      content={content}\n      timestamp={event.createdAt}\n    />\n  );\n}\n\nfunction WebsocketEventDetail({\n  event,\n  hexDump,\n  setHexDump,\n  showLarge,\n  showingLarge,\n  setShowLarge,\n  setShowingLarge,\n  onClose,\n}: {\n  event: WebsocketEvent;\n  hexDump: boolean;\n  setHexDump: (v: boolean) => void;\n  showLarge: boolean;\n  showingLarge: boolean;\n  setShowLarge: (v: boolean) => void;\n  setShowingLarge: (v: boolean) => void;\n  onClose: () => void;\n}) {\n  const message = useMemo(() => {\n    if (hexDump) {\n      return event.message ? hexy(event.message) : \"\";\n    }\n    return event.message ? new TextDecoder(\"utf-8\").decode(Uint8Array.from(event.message)) : \"\";\n  }, [event.message, hexDump]);\n\n  const language = languageFromContentType(null, message);\n  const formattedMessage = useFormatText({ language, text: message, pretty: true });\n\n  const title =\n    event.messageType === \"close\"\n      ? \"Connection Closed\"\n      : event.messageType === \"open\"\n        ? \"Connection Open\"\n        : `Message ${event.isServer ? \"Received\" : \"Sent\"}`;\n\n  const actions: EventDetailAction[] =\n    message !== \"\"\n      ? [\n          {\n            key: \"toggle-hexdump\",\n            label: hexDump ? \"Show Message\" : \"Show Hexdump\",\n            onClick: () => setHexDump(!hexDump),\n          },\n        ]\n      : [];\n\n  return (\n    <div className=\"h-full grid grid-rows-[auto_minmax(0,1fr)]\">\n      <EventDetailHeader\n        title={title}\n        timestamp={event.createdAt}\n        actions={actions}\n        copyText={formattedMessage || undefined}\n        onClose={onClose}\n      />\n      {!showLarge && event.message.length > 1000 * 1000 ? (\n        <VStack space={2} className=\"italic text-text-subtlest\">\n          Message previews larger than 1MB are hidden\n          <div>\n            <Button\n              onClick={() => {\n                setShowingLarge(true);\n                setTimeout(() => {\n                  setShowLarge(true);\n                  setShowingLarge(false);\n                }, 500);\n              }}\n              isLoading={showingLarge}\n              color=\"secondary\"\n              variant=\"border\"\n              size=\"xs\"\n            >\n              Try Showing\n            </Button>\n          </div>\n        </VStack>\n      ) : event.message.length === 0 ? (\n        <EmptyStateText>No Content</EmptyStateText>\n      ) : (\n        <Editor\n          language={language}\n          defaultValue={formattedMessage ?? \"\"}\n          wrapLines={false}\n          readOnly={true}\n          stateKey={null}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/WindowControls.tsx",
    "content": "import { getCurrentWebviewWindow } from \"@tauri-apps/api/webviewWindow\";\nimport { type } from \"@tauri-apps/plugin-os\";\nimport { settingsAtom } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport { useState } from \"react\";\nimport { WINDOW_CONTROLS_WIDTH } from \"../lib/constants\";\nimport { Button } from \"./core/Button\";\nimport { HStack } from \"./core/Stacks\";\n\ninterface Props {\n  className?: string;\n  onlyX?: boolean;\n  macos?: boolean;\n}\n\nexport function WindowControls({ className, onlyX }: Props) {\n  const [maximized, setMaximized] = useState<boolean>(false);\n  const settings = useAtomValue(settingsAtom);\n  // Never show controls on macOS or if hideWindowControls is true\n  if (type() === \"macos\" || settings.hideWindowControls || settings.useNativeTitlebar) {\n    return null;\n  }\n\n  return (\n    <HStack\n      className={classNames(className, \"ml-4 absolute right-0 top-0 bottom-0\")}\n      justifyContent=\"end\"\n      style={{ width: WINDOW_CONTROLS_WIDTH }}\n      data-tauri-drag-region\n    >\n      {!onlyX && (\n        <>\n          <Button\n            className=\"!h-full px-4 text-text-subtle hocus:text hocus:bg-surface-highlight rounded-none\"\n            color=\"custom\"\n            onClick={() => getCurrentWebviewWindow().minimize()}\n          >\n            <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\">\n              <title>Minimize</title>\n              <path fill=\"currentColor\" d=\"M14 8v1H3V8z\" />\n            </svg>\n          </Button>\n          <Button\n            className=\"!h-full px-4 text-text-subtle hocus:text hocus:bg-surface-highlight rounded-none\"\n            color=\"custom\"\n            onClick={async () => {\n              const w = getCurrentWebviewWindow();\n              const isMaximized = await w.isMaximized();\n              if (isMaximized) {\n                await w.unmaximize();\n                setMaximized(false);\n              } else {\n                await w.maximize();\n                setMaximized(true);\n              }\n            }}\n          >\n            {maximized ? (\n              <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\">\n                <title>Unmaximize</title>\n                <g fill=\"currentColor\">\n                  <path d=\"M3 5v9h9V5zm8 8H4V6h7z\" />\n                  <path fillRule=\"evenodd\" d=\"M5 5h1V4h7v7h-1v1h2V3H5z\" clipRule=\"evenodd\" />\n                </g>\n              </svg>\n            ) : (\n              <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\">\n                <title>Maximize</title>\n                <path fill=\"currentColor\" d=\"M3 3v10h10V3zm9 9H4V4h8z\" />\n              </svg>\n            )}\n          </Button>\n        </>\n      )}\n      <Button\n        color=\"custom\"\n        className=\"!h-full px-4 text-text-subtle rounded-none hocus:bg-danger hocus:text-text\"\n        onClick={() => getCurrentWebviewWindow().close()}\n      >\n        <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 16 16\">\n          <title>Close</title>\n          <path\n            fill=\"currentColor\"\n            fillRule=\"evenodd\"\n            d=\"m7.116 8l-4.558 4.558l.884.884L8 8.884l4.558 4.558l.884-.884L8.884 8l4.558-4.558l-.884-.884L8 7.116L3.442 2.558l-.884.884z\"\n            clipRule=\"evenodd\"\n          />\n        </svg>\n      </Button>\n    </HStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/Workspace.tsx",
    "content": "import { workspacesAtom } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport * as m from \"motion/react-m\";\nimport type { CSSProperties } from \"react\";\nimport { useCallback, useMemo, useRef, useState } from \"react\";\nimport {\n  useEnsureActiveCookieJar,\n  useSubscribeActiveCookieJarId,\n} from \"../hooks/useActiveCookieJar\";\nimport {\n  activeEnvironmentAtom,\n  useSubscribeActiveEnvironmentId,\n} from \"../hooks/useActiveEnvironment\";\nimport { activeFolderAtom } from \"../hooks/useActiveFolder\";\nimport { useSubscribeActiveFolderId } from \"../hooks/useActiveFolderId\";\nimport { activeRequestAtom } from \"../hooks/useActiveRequest\";\nimport { useSubscribeActiveRequestId } from \"../hooks/useActiveRequestId\";\nimport { activeWorkspaceAtom } from \"../hooks/useActiveWorkspace\";\nimport { useFloatingSidebarHidden } from \"../hooks/useFloatingSidebarHidden\";\nimport { useHotKey } from \"../hooks/useHotKey\";\nimport { useSubscribeRecentCookieJars } from \"../hooks/useRecentCookieJars\";\nimport { useSubscribeRecentEnvironments } from \"../hooks/useRecentEnvironments\";\nimport { useSubscribeRecentRequests } from \"../hooks/useRecentRequests\";\nimport { useSubscribeRecentWorkspaces } from \"../hooks/useRecentWorkspaces\";\nimport { useShouldFloatSidebar } from \"../hooks/useShouldFloatSidebar\";\nimport { useSidebarHidden } from \"../hooks/useSidebarHidden\";\nimport { useSidebarWidth } from \"../hooks/useSidebarWidth\";\nimport { useSyncWorkspaceRequestTitle } from \"../hooks/useSyncWorkspaceRequestTitle\";\nimport { duplicateRequestOrFolderAndNavigate } from \"../lib/duplicateRequestOrFolderAndNavigate\";\nimport { importData } from \"../lib/importData\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { CreateDropdown } from \"./CreateDropdown\";\nimport { Banner } from \"./core/Banner\";\nimport { Button } from \"./core/Button\";\nimport { HotkeyList } from \"./core/HotkeyList\";\nimport { FeedbackLink } from \"./core/Link\";\nimport { HStack } from \"./core/Stacks\";\nimport { ErrorBoundary } from \"./ErrorBoundary\";\nimport { FolderLayout } from \"./FolderLayout\";\nimport { GrpcConnectionLayout } from \"./GrpcConnectionLayout\";\nimport { HeaderSize } from \"./HeaderSize\";\nimport { HttpRequestLayout } from \"./HttpRequestLayout\";\nimport { Overlay } from \"./Overlay\";\nimport type { ResizeHandleEvent } from \"./ResizeHandle\";\nimport { ResizeHandle } from \"./ResizeHandle\";\nimport Sidebar from \"./Sidebar\";\nimport { SidebarActions } from \"./SidebarActions\";\nimport { WebsocketRequestLayout } from \"./WebsocketRequestLayout\";\nimport { WorkspaceHeader } from \"./WorkspaceHeader\";\n\nconst side = { gridArea: \"side\" };\nconst head = { gridArea: \"head\" };\nconst body = { gridArea: \"body\" };\nconst drag = { gridArea: \"drag\" };\n\nexport function Workspace() {\n  // First, subscribe to some things applicable to workspaces\n  useGlobalWorkspaceHooks();\n\n  const workspaces = useAtomValue(workspacesAtom);\n  const [width, setWidth, resetWidth] = useSidebarWidth();\n  const [sidebarHidden, setSidebarHidden] = useSidebarHidden();\n  const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();\n  const activeEnvironment = useAtomValue(activeEnvironmentAtom);\n  const floating = useShouldFloatSidebar();\n  const [isResizing, setIsResizing] = useState<boolean>(false);\n  const startWidth = useRef<number | null>(null);\n\n  const handleResizeMove = useCallback(\n    async ({ x, xStart }: ResizeHandleEvent) => {\n      if (width == null || startWidth.current == null) return;\n\n      const newWidth = startWidth.current + (x - xStart);\n      if (newWidth < 50) {\n        if (!sidebarHidden) await setSidebarHidden(true);\n        resetWidth();\n      } else {\n        if (sidebarHidden) await setSidebarHidden(false);\n        setWidth(newWidth);\n      }\n    },\n    [width, sidebarHidden, setSidebarHidden, resetWidth, setWidth],\n  );\n\n  const handleResizeStart = useCallback(() => {\n    startWidth.current = width ?? null;\n    setIsResizing(true);\n  }, [width]);\n\n  const handleResizeEnd = useCallback(() => {\n    setIsResizing(false);\n    startWidth.current = null;\n  }, []);\n\n  const sideWidth = sidebarHidden ? 0 : width;\n  const styles = useMemo<CSSProperties>(\n    () => ({\n      gridTemplate: floating\n        ? `\n        ' ${head.gridArea}' auto\n        ' ${body.gridArea}' minmax(0,1fr)\n        / 1fr`\n        : `\n        ' ${head.gridArea} ${head.gridArea} ${head.gridArea}' auto\n        ' ${side.gridArea} ${drag.gridArea} ${body.gridArea}' minmax(0,1fr)\n        / ${sideWidth}px   0                1fr`,\n    }),\n    [sideWidth, floating],\n  );\n\n  const environmentBgStyle = useMemo(() => {\n    if (activeEnvironment?.color == null) return undefined;\n    const background = `linear-gradient(to right, ${activeEnvironment.color} 15%, transparent 40%)`;\n    return { background };\n  }, [activeEnvironment?.color]);\n\n  // We're loading still\n  if (workspaces.length === 0) {\n    return null;\n  }\n\n  return (\n    <div\n      style={styles}\n      className={classNames(\n        \"grid w-full h-full\",\n        // Animate sidebar width changes but only when not resizing\n        // because it's too slow to animate on mouse move\n        !isResizing && \"transition-grid\",\n      )}\n    >\n      {floating ? (\n        <Overlay\n          open={!floatingSidebarHidden}\n          portalName=\"sidebar\"\n          onClose={() => setFloatingSidebarHidden(true)}\n          zIndex={20}\n        >\n          <m.div\n            initial={{ opacity: 0, x: -20 }}\n            animate={{ opacity: 1, x: 0 }}\n            className={classNames(\n              \"x-theme-sidebar\",\n              \"absolute top-0 left-0 bottom-0 bg-surface border-r border-border-subtle w-[20rem]\",\n              \"grid grid-rows-[auto_1fr]\",\n            )}\n          >\n            <HeaderSize hideControls size=\"lg\" className=\"border-transparent flex items-center\">\n              <SidebarActions />\n            </HeaderSize>\n            <ErrorBoundary name=\"Sidebar (Floating)\">\n              <Sidebar />\n            </ErrorBoundary>\n          </m.div>\n        </Overlay>\n      ) : (\n        <>\n          <div style={side} className={classNames(\"x-theme-sidebar\", \"overflow-hidden bg-surface\")}>\n            <ErrorBoundary name=\"Sidebar\">\n              <Sidebar className=\"border-r border-border-subtle\" />\n            </ErrorBoundary>\n          </div>\n          <ResizeHandle\n            style={drag}\n            className=\"-translate-x-[1px]\"\n            justify=\"end\"\n            side=\"right\"\n            onResizeStart={handleResizeStart}\n            onResizeEnd={handleResizeEnd}\n            onResizeMove={handleResizeMove}\n            onReset={resetWidth}\n          />\n        </>\n      )}\n      <HeaderSize\n        data-tauri-drag-region\n        size=\"lg\"\n        className=\"relative x-theme-appHeader bg-surface\"\n        style={head}\n      >\n        <div className=\"absolute inset-0 pointer-events-none\">\n          <div // Add subtle background\n            style={environmentBgStyle}\n            className=\"absolute inset-0 opacity-[0.07]\"\n          />\n          <div // Add a subtle border bottom\n            style={environmentBgStyle}\n            className=\"absolute left-0 right-0 -bottom-[1px] h-[1px] opacity-20\"\n          />\n        </div>\n        <WorkspaceHeader className=\"pointer-events-none\" />\n      </HeaderSize>\n      <ErrorBoundary name=\"Workspace Body\">\n        <WorkspaceBody />\n      </ErrorBoundary>\n    </div>\n  );\n}\n\nfunction WorkspaceBody() {\n  const activeRequest = useAtomValue(activeRequestAtom);\n  const activeFolder = useAtomValue(activeFolderAtom);\n  const activeWorkspace = useAtomValue(activeWorkspaceAtom);\n\n  if (activeWorkspace == null) {\n    return (\n      <m.div\n        className=\"m-auto\"\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        // Delay the entering because the workspaces might load after a slight delay\n        transition={{ delay: 0.5 }}\n      >\n        <Banner color=\"warning\" className=\"max-w-[30rem]\">\n          The active workspace was not found. Select a workspace from the header menu or report this\n          bug to <FeedbackLink />\n        </Banner>\n      </m.div>\n    );\n  }\n\n  if (activeRequest?.model === \"grpc_request\") {\n    return <GrpcConnectionLayout style={body} />;\n  }\n  if (activeRequest?.model === \"websocket_request\") {\n    return <WebsocketRequestLayout style={body} activeRequest={activeRequest} />;\n  }\n  if (activeRequest?.model === \"http_request\") {\n    return <HttpRequestLayout activeRequest={activeRequest} style={body} />;\n  }\n  if (activeFolder != null) {\n    return <FolderLayout folder={activeFolder} style={body} />;\n  }\n\n  return (\n    <HotkeyList\n      hotkeys={[\"model.create\", \"sidebar.focus\", \"settings.show\"]}\n      bottomSlot={\n        <HStack space={1} justifyContent=\"center\" className=\"mt-3\">\n          <Button variant=\"border\" size=\"sm\" onClick={() => importData.mutate()}>\n            Import\n          </Button>\n          <CreateDropdown hideFolder>\n            <Button variant=\"border\" forDropdown size=\"sm\">\n              New Request\n            </Button>\n          </CreateDropdown>\n        </HStack>\n      }\n    />\n  );\n}\n\nfunction useGlobalWorkspaceHooks() {\n  useEnsureActiveCookieJar();\n\n  useSubscribeActiveRequestId();\n  useSubscribeActiveFolderId();\n  useSubscribeActiveEnvironmentId();\n  useSubscribeActiveCookieJarId();\n\n  useSubscribeRecentRequests();\n  useSubscribeRecentWorkspaces();\n  useSubscribeRecentEnvironments();\n  useSubscribeRecentCookieJars();\n\n  useSyncWorkspaceRequestTitle();\n\n  useHotKey(\"model.duplicate\", () =>\n    duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),\n  );\n}\n"
  },
  {
    "path": "src-web/components/WorkspaceActionsDropdown.tsx",
    "content": "import { open } from \"@tauri-apps/plugin-dialog\";\nimport { revealItemInDir } from \"@tauri-apps/plugin-opener\";\nimport { getModel, settingsAtom, workspacesAtom } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport { memo, useCallback, useMemo } from \"react\";\nimport { openWorkspaceFromSyncDir } from \"../commands/openWorkspaceFromSyncDir\";\nimport { openWorkspaceSettings } from \"../commands/openWorkspaceSettings\";\nimport { switchWorkspace } from \"../commands/switchWorkspace\";\nimport {\n  activeWorkspaceAtom,\n  activeWorkspaceIdAtom,\n  activeWorkspaceMetaAtom,\n} from \"../hooks/useActiveWorkspace\";\nimport { useCreateWorkspace } from \"../hooks/useCreateWorkspace\";\nimport { useDeleteSendHistory } from \"../hooks/useDeleteSendHistory\";\nimport { useWorkspaceActions } from \"../hooks/useWorkspaceActions\";\nimport { showDialog } from \"../lib/dialog\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { revealInFinderText } from \"../lib/reveal\";\nimport { CloneGitRepositoryDialog } from \"./CloneGitRepositoryDialog\";\nimport type { ButtonProps } from \"./core/Button\";\nimport { Button } from \"./core/Button\";\nimport type { DropdownItem } from \"./core/Dropdown\";\nimport { Icon } from \"./core/Icon\";\nimport type { RadioDropdownItem } from \"./core/RadioDropdown\";\nimport { RadioDropdown } from \"./core/RadioDropdown\";\nimport { SwitchWorkspaceDialog } from \"./SwitchWorkspaceDialog\";\n\ntype Props = Pick<ButtonProps, \"className\" | \"justify\" | \"forDropdown\" | \"leftSlot\">;\n\nexport const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({\n  className,\n  ...buttonProps\n}: Props) {\n  const workspaces = useAtomValue(workspacesAtom);\n  const workspace = useAtomValue(activeWorkspaceAtom);\n  const createWorkspace = useCreateWorkspace();\n  const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);\n  const { mutate: deleteSendHistory } = useDeleteSendHistory();\n  const workspaceActions = useWorkspaceActions();\n\n  const openCloneGitRepositoryDialog = useCallback(() => {\n    showDialog({\n      id: \"clone-git-repository\",\n      size: \"md\",\n      title: \"Clone Git Repository\",\n      render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,\n    });\n  }, []);\n\n  const { workspaceItems, itemsAfter, itemsBefore } = useMemo<{\n    workspaceItems: RadioDropdownItem[];\n    itemsAfter: DropdownItem[];\n    itemsBefore: DropdownItem[];\n  }>(() => {\n    const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({\n      key: w.id,\n      label: w.name,\n      value: w.id,\n      leftSlot: w.id === workspace?.id ? <Icon icon=\"check\" /> : <Icon icon=\"empty\" />,\n    }));\n\n    const itemsBefore: DropdownItem[] = [\n      {\n        label: \"New Workspace\",\n        leftSlot: <Icon icon=\"plus\" />,\n        submenu: [\n          {\n            label: \"Create Empty\",\n            leftSlot: <Icon icon=\"plus_circle\" />,\n            onSelect: createWorkspace,\n          },\n          {\n            label: \"Open Folder\",\n            leftSlot: <Icon icon=\"folder_open\" />,\n            onSelect: async () => {\n              const dir = await open({\n                title: \"Select Workspace Directory\",\n                directory: true,\n                multiple: false,\n              });\n\n              if (dir == null) return;\n              openWorkspaceFromSyncDir.mutate(dir);\n            },\n          },\n          {\n            label: \"Clone Git Repository\",\n            leftSlot: <Icon icon=\"hard_drive_download\" />,\n            onSelect: openCloneGitRepositoryDialog,\n          },\n        ],\n      },\n    ];\n    const itemsAfter: DropdownItem[] = [\n      ...workspaceActions.map((a) => ({\n        label: a.label,\n        leftSlot: <Icon icon={a.icon ?? \"empty\"} />,\n        onSelect: async () => {\n          if (workspace != null) await a.call(workspace);\n        },\n      })),\n      ...(workspaceActions.length > 0 ? [{ type: \"separator\" as const }] : []),\n      {\n        label: \"Workspace Settings\",\n        leftSlot: <Icon icon=\"settings\" />,\n        hotKeyAction: \"workspace_settings.show\",\n        onSelect: openWorkspaceSettings,\n      },\n      {\n        label: revealInFinderText,\n        hidden: workspaceMeta == null || workspaceMeta.settingSyncDir == null,\n        leftSlot: <Icon icon=\"folder_symlink\" />,\n        onSelect: async () => {\n          if (workspaceMeta?.settingSyncDir == null) return;\n          await revealItemInDir(workspaceMeta.settingSyncDir);\n        },\n      },\n      {\n        label: \"Clear Send History\",\n        color: \"warning\",\n        leftSlot: <Icon icon=\"history\" />,\n        onSelect: deleteSendHistory,\n      },\n    ];\n\n    return { workspaceItems, itemsAfter, itemsBefore };\n  }, [\n    workspaces,\n    workspaceMeta,\n    deleteSendHistory,\n    createWorkspace,\n    openCloneGitRepositoryDialog,\n    workspace?.id,\n    workspace,\n    workspaceActions.map,\n    workspaceActions.length,\n  ]);\n\n  const handleSwitchWorkspace = useCallback(async (workspaceId: string | null) => {\n    if (workspaceId == null) return;\n\n    const settings = jotaiStore.get(settingsAtom);\n    const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n    if (workspaceId === activeWorkspaceId) {\n      // Always open a new window if the selected one is already active\n      switchWorkspace.mutate({ workspaceId, inNewWindow: true });\n      return;\n    }\n    if (typeof settings.openWorkspaceNewWindow === \"boolean\") {\n      switchWorkspace.mutate({ workspaceId, inNewWindow: settings.openWorkspaceNewWindow });\n      return;\n    }\n\n    const workspace = getModel(\"workspace\", workspaceId);\n    if (workspace == null) return;\n\n    showDialog({\n      id: \"switch-workspace\",\n      size: \"sm\",\n      title: \"Switch Workspace\",\n      render: ({ hide }) => <SwitchWorkspaceDialog workspace={workspace} hide={hide} />,\n    });\n  }, []);\n\n  return (\n    <RadioDropdown\n      items={workspaceItems}\n      itemsAfter={itemsAfter}\n      itemsBefore={itemsBefore}\n      onChange={handleSwitchWorkspace}\n      value={workspace?.id ?? null}\n    >\n      <Button\n        size=\"sm\"\n        className={classNames(\n          className,\n          \"text !px-2 truncate\",\n          workspace === null && \"italic opacity-disabled\",\n        )}\n        {...buttonProps}\n      >\n        {workspace?.name ?? \"Workspace\"}\n      </Button>\n    </RadioDropdown>\n  );\n});\n"
  },
  {
    "path": "src-web/components/WorkspaceEncryptionSetting.tsx",
    "content": "import {\n  disableEncryption,\n  enableEncryption,\n  revealWorkspaceKey,\n  setWorkspaceKey,\n} from \"@yaakapp-internal/crypto\";\nimport type { WorkspaceMeta } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport { useEffect, useState } from \"react\";\nimport { activeWorkspaceAtom, activeWorkspaceMetaAtom } from \"../hooks/useActiveWorkspace\";\nimport { createFastMutation } from \"../hooks/useFastMutation\";\nimport { useStateWithDeps } from \"../hooks/useStateWithDeps\";\nimport { showConfirm } from \"../lib/confirm\";\nimport { CopyIconButton } from \"./CopyIconButton\";\nimport { Banner } from \"./core/Banner\";\nimport type { ButtonProps } from \"./core/Button\";\nimport { Button } from \"./core/Button\";\nimport { IconButton } from \"./core/IconButton\";\nimport { IconTooltip } from \"./core/IconTooltip\";\nimport { Label } from \"./core/Label\";\nimport { PlainInput } from \"./core/PlainInput\";\nimport { HStack, VStack } from \"./core/Stacks\";\nimport { EncryptionHelp } from \"./EncryptionHelp\";\n\ninterface Props {\n  size?: ButtonProps[\"size\"];\n  expanded?: boolean;\n  onDone?: () => void;\n  onEnabledEncryption?: () => void;\n}\n\nexport function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEncryption }: Props) {\n  const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const workspace = useAtomValue(activeWorkspaceAtom);\n  const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);\n  const [key, setKey] = useState<{ key: string | null; error: string | null } | null>(null);\n\n  useEffect(() => {\n    if (workspaceMeta == null) {\n      return;\n    }\n\n    if (workspaceMeta?.encryptionKey == null) {\n      setKey({ key: null, error: null });\n      return;\n    }\n\n    revealWorkspaceKey(workspaceMeta.workspaceId).then(\n      (key) => {\n        setKey({ key, error: null });\n      },\n      (err) => {\n        setKey({ key: null, error: `${err}` });\n      },\n    );\n  }, [workspaceMeta, workspaceMeta?.encryptionKey]);\n\n  if (key == null || workspace == null || workspaceMeta == null) {\n    return null;\n  }\n\n  // Prompt for key if it doesn't exist or could not be decrypted\n  if (\n    key.error != null ||\n    (workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null)\n  ) {\n    return (\n      <EnterWorkspaceKey\n        workspaceMeta={workspaceMeta}\n        error={key.error}\n        onEnabled={() => {\n          onDone?.();\n          onEnabledEncryption?.();\n        }}\n        onDisabled={() => {\n          onDone?.();\n        }}\n      />\n    );\n  }\n\n  // Show the key if it exists\n  if (workspaceMeta.encryptionKey && key.key != null) {\n    const keyRevealer = (\n      <KeyRevealer\n        disableLabel={justEnabledEncryption}\n        defaultShow={justEnabledEncryption}\n        encryptionKey={key.key}\n      />\n    );\n    return (\n      <VStack space={2} className=\"w-full\">\n        {justEnabledEncryption && (\n          <Banner color=\"success\" className=\"flex flex-col gap-2\">\n            {helpAfterEncryption}\n          </Banner>\n        )}\n        {keyRevealer}\n        {onDone && (\n          <Button\n            color=\"secondary\"\n            onClick={() => {\n              onDone();\n              onEnabledEncryption?.();\n            }}\n          >\n            Done\n          </Button>\n        )}\n      </VStack>\n    );\n  }\n\n  // Show button to enable encryption\n  return (\n    <div className=\"mb-auto flex flex-col-reverse\">\n      <Button\n        className=\"mt-3\"\n        color={expanded ? \"info\" : \"secondary\"}\n        size={size}\n        onClick={async () => {\n          setError(null);\n          try {\n            await enableEncryption(workspaceMeta.workspaceId);\n            setJustEnabledEncryption(true);\n          } catch (err) {\n            setError(\n              `Failed to enable encryption: ${err instanceof Error ? err.message : String(err)}`,\n            );\n          }\n        }}\n      >\n        Enable Encryption\n      </Button>\n      {error && (\n        <Banner color=\"danger\" className=\"mb-2\">\n          {error}\n        </Banner>\n      )}\n      {expanded ? (\n        <Banner color=\"info\" className=\"mb-6\">\n          <EncryptionHelp />\n        </Banner>\n      ) : (\n        <Label htmlFor={null} help={<EncryptionHelp />}>\n          Workspace encryption\n        </Label>\n      )}\n    </div>\n  );\n}\n\nconst setWorkspaceKeyMut = createFastMutation({\n  mutationKey: [\"set-workspace-key\"],\n  mutationFn: setWorkspaceKey,\n});\n\nfunction EnterWorkspaceKey({\n  workspaceMeta,\n  onEnabled,\n  onDisabled,\n  error,\n}: {\n  workspaceMeta: WorkspaceMeta;\n  onEnabled?: () => void;\n  onDisabled?: () => void;\n  error?: string | null;\n}) {\n  const [key, setKey] = useState<string>(\"\");\n\n  const handleForgotKey = async () => {\n    const confirmed = await showConfirm({\n      id: \"disable-encryption\",\n      title: \"Disable Encryption\",\n      color: \"danger\",\n      confirmText: \"Disable Encryption\",\n      description: (\n        <>\n          This will disable encryption for this workspace. Any previously encrypted values will fail\n          to decrypt and will need to be re-entered manually.\n          <br />\n          <br />\n          This action cannot be undone.\n        </>\n      ),\n    });\n\n    if (confirmed) {\n      await disableEncryption(workspaceMeta.workspaceId);\n      onDisabled?.();\n    }\n  };\n\n  return (\n    <VStack space={4} className=\"w-full\">\n      {error ? (\n        <Banner color=\"danger\">{error}</Banner>\n      ) : (\n        <Banner color=\"info\">\n          This workspace contains encrypted values but no key is configured. Please enter the\n          workspace key to access the encrypted data.\n        </Banner>\n      )}\n      <HStack\n        as=\"form\"\n        alignItems=\"end\"\n        className=\"w-full\"\n        space={1.5}\n        onSubmit={(e) => {\n          e.preventDefault();\n          setWorkspaceKeyMut.mutate(\n            {\n              workspaceId: workspaceMeta.workspaceId,\n              key: key.trim(),\n            },\n            { onSuccess: onEnabled },\n          );\n        }}\n      >\n        <PlainInput\n          required\n          onChange={setKey}\n          label=\"Workspace encryption key\"\n          placeholder=\"YK0000-111111-222222-333333-444444-AAAAAA-BBBBBB-CCCCCC-DDDDDD\"\n        />\n        <Button variant=\"border\" type=\"submit\" color=\"secondary\">\n          Submit\n        </Button>\n      </HStack>\n      <button\n        type=\"button\"\n        onClick={handleForgotKey}\n        className=\"text-text-subtlest text-sm hover:text-text-subtle\"\n      >\n        Forgot your key?\n      </button>\n    </VStack>\n  );\n}\n\nfunction KeyRevealer({\n  defaultShow = false,\n  disableLabel = false,\n  encryptionKey,\n}: {\n  defaultShow?: boolean;\n  disableLabel?: boolean;\n  encryptionKey: string;\n}) {\n  const [show, setShow] = useStateWithDeps<boolean>(defaultShow, [defaultShow]);\n\n  return (\n    <div\n      className={classNames(\n        \"w-full border border-border rounded-md pl-3 py-2 p-1\",\n        \"grid gap-1 grid-cols-[minmax(0,1fr)_auto] items-center\",\n      )}\n    >\n      <VStack space={0.5}>\n        {!disableLabel && (\n          <span className=\"text-sm text-primary flex items-center gap-1\">\n            Workspace encryption key{\" \"}\n            <IconTooltip iconSize=\"sm\" size=\"lg\" content={helpAfterEncryption} />\n          </span>\n        )}\n        {encryptionKey && <HighlightedKey keyText={encryptionKey} show={show} />}\n      </VStack>\n      <HStack>\n        {encryptionKey && <CopyIconButton text={encryptionKey} title=\"Copy workspace key\" />}\n        <IconButton\n          title={show ? \"Hide\" : \"Reveal\" + \"workspace key\"}\n          icon={show ? \"eye_closed\" : \"eye\"}\n          onClick={() => setShow((v) => !v)}\n        />\n      </HStack>\n    </div>\n  );\n}\n\nfunction HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) {\n  return (\n    <span className=\"text-xs font-mono [&_*]:cursor-auto [&_*]:select-text\">\n      {show ? (\n        keyText.split(\"\").map((c, i) => {\n          return (\n            <span\n              // oxlint-disable-next-line react/no-array-index-key\n              key={i}\n              className={classNames(\n                c.match(/[0-9]/) && \"text-info\",\n                c === \"-\" && \"text-text-subtle\",\n              )}\n            >\n              {c}\n            </span>\n          );\n        })\n      ) : (\n        <div className=\"text-text-subtle\">•••••••••••••••••••••</div>\n      )}\n    </span>\n  );\n}\n\nconst helpAfterEncryption = (\n  <p>\n    The following key is used for encryption operations within this workspace. It is stored securely\n    using your OS keychain, but it is recommended to back it up. If you share this workspace with\n    others, you&apos;ll need to send them this key to access any encrypted values.\n  </p>\n);\n"
  },
  {
    "path": "src-web/components/WorkspaceHeader.tsx",
    "content": "import classNames from \"classnames\";\nimport { useAtom, useAtomValue } from \"jotai\";\nimport { memo } from \"react\";\nimport { activeWorkspaceAtom, activeWorkspaceMetaAtom } from \"../hooks/useActiveWorkspace\";\nimport { useToggleCommandPalette } from \"../hooks/useToggleCommandPalette\";\nimport { workspaceLayoutAtom } from \"../lib/atoms\";\nimport { setupOrConfigureEncryption } from \"../lib/setupOrConfigureEncryption\";\nimport { CookieDropdown } from \"./CookieDropdown\";\nimport { Icon } from \"./core/Icon\";\nimport { IconButton } from \"./core/IconButton\";\nimport { PillButton } from \"./core/PillButton\";\nimport { HStack } from \"./core/Stacks\";\nimport { EnvironmentActionsDropdown } from \"./EnvironmentActionsDropdown\";\nimport { ImportCurlButton } from \"./ImportCurlButton\";\nimport { LicenseBadge } from \"./LicenseBadge\";\nimport { RecentRequestsDropdown } from \"./RecentRequestsDropdown\";\nimport { SettingsDropdown } from \"./SettingsDropdown\";\nimport { SidebarActions } from \"./SidebarActions\";\nimport { WorkspaceActionsDropdown } from \"./WorkspaceActionsDropdown\";\n\ninterface Props {\n  className?: string;\n}\n\nexport const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {\n  const togglePalette = useToggleCommandPalette();\n  const [workspaceLayout, setWorkspaceLayout] = useAtom(workspaceLayoutAtom);\n  const workspace = useAtomValue(activeWorkspaceAtom);\n  const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);\n  const showEncryptionSetup =\n    workspace != null &&\n    workspaceMeta != null &&\n    workspace.encryptionKeyChallenge != null &&\n    workspaceMeta.encryptionKey == null;\n\n  return (\n    <div\n      className={classNames(\n        className,\n        \"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full h-full\",\n      )}\n    >\n      <HStack space={0.5} className={classNames(\"flex-1 pointer-events-none\")}>\n        <SidebarActions />\n        <CookieDropdown />\n        <HStack className=\"min-w-0\">\n          <WorkspaceActionsDropdown />\n          <Icon icon=\"chevron_right\" color=\"secondary\" />\n          <EnvironmentActionsDropdown className=\"w-auto pointer-events-auto\" />\n        </HStack>\n      </HStack>\n      <div className=\"pointer-events-none w-full max-w-[30vw] mx-auto flex justify-center\">\n        <RecentRequestsDropdown />\n      </div>\n      <div className=\"flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1\">\n        <ImportCurlButton />\n        {showEncryptionSetup ? (\n          <PillButton color=\"danger\" onClick={setupOrConfigureEncryption}>\n            Enter Encryption Key\n          </PillButton>\n        ) : (\n          <LicenseBadge />\n        )}\n        <IconButton\n          icon={\n            workspaceLayout === \"responsive\"\n              ? \"magic_wand\"\n              : workspaceLayout === \"horizontal\"\n                ? \"columns_2\"\n                : \"rows_2\"\n          }\n          title={`Change to ${workspaceLayout === \"horizontal\" ? \"vertical\" : \"horizontal\"} layout`}\n          size=\"sm\"\n          iconColor=\"secondary\"\n          onClick={() =>\n            setWorkspaceLayout((prev) => (prev === \"horizontal\" ? \"vertical\" : \"horizontal\"))\n          }\n        />\n        <IconButton\n          icon=\"search\"\n          title=\"Search or execute a command\"\n          size=\"sm\"\n          hotkeyAction=\"command_palette.toggle\"\n          iconColor=\"secondary\"\n          onClick={togglePalette}\n        />\n        <SettingsDropdown />\n      </div>\n    </div>\n  );\n});\n"
  },
  {
    "path": "src-web/components/WorkspaceSettingsDialog.tsx",
    "content": "import { patchModel, workspaceMetasAtom, workspacesAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useAuthTab } from \"../hooks/useAuthTab\";\nimport { useHeadersTab } from \"../hooks/useHeadersTab\";\nimport { useInheritedHeaders } from \"../hooks/useInheritedHeaders\";\nimport { deleteModelWithConfirm } from \"../lib/deleteModelWithConfirm\";\nimport { router } from \"../lib/router\";\nimport { CopyIconButton } from \"./CopyIconButton\";\nimport { Banner } from \"./core/Banner\";\nimport { Button } from \"./core/Button\";\nimport { CountBadge } from \"./core/CountBadge\";\nimport { InlineCode } from \"./core/InlineCode\";\nimport { PlainInput } from \"./core/PlainInput\";\nimport { HStack, VStack } from \"./core/Stacks\";\nimport { TabContent, Tabs } from \"./core/Tabs/Tabs\";\nimport { DnsOverridesEditor } from \"./DnsOverridesEditor\";\nimport { HeadersEditor } from \"./HeadersEditor\";\nimport { HttpAuthenticationEditor } from \"./HttpAuthenticationEditor\";\nimport { MarkdownEditor } from \"./MarkdownEditor\";\nimport { SyncToFilesystemSetting } from \"./SyncToFilesystemSetting\";\nimport { WorkspaceEncryptionSetting } from \"./WorkspaceEncryptionSetting\";\n\ninterface Props {\n  workspaceId: string;\n  hide: () => void;\n  tab?: WorkspaceSettingsTab;\n}\n\nconst TAB_AUTH = \"auth\";\nconst TAB_DATA = \"data\";\nconst TAB_DNS = \"dns\";\nconst TAB_HEADERS = \"headers\";\nconst TAB_GENERAL = \"general\";\n\nexport type WorkspaceSettingsTab =\n  | typeof TAB_AUTH\n  | typeof TAB_DNS\n  | typeof TAB_HEADERS\n  | typeof TAB_GENERAL\n  | typeof TAB_DATA;\n\nconst DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;\n\nexport function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {\n  const workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId);\n  const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId);\n  const authTab = useAuthTab(TAB_AUTH, workspace ?? null);\n  const headersTab = useHeadersTab(TAB_HEADERS, workspace ?? null);\n  const inheritedHeaders = useInheritedHeaders(workspace ?? null);\n\n  if (workspace == null) {\n    return (\n      <Banner color=\"danger\">\n        <InlineCode>Workspace</InlineCode> not found\n      </Banner>\n    );\n  }\n\n  if (workspaceMeta == null)\n    return (\n      <Banner color=\"danger\">\n        <InlineCode>WorkspaceMeta</InlineCode> not found for workspace\n      </Banner>\n    );\n\n  return (\n    <Tabs\n      defaultValue={tab ?? DEFAULT_TAB}\n      label=\"Folder Settings\"\n      className=\"pt-4 pb-2 px-3\"\n      tabListClassName=\"pl-4\"\n      addBorders\n      tabs={[\n        { value: TAB_GENERAL, label: \"Workspace\" },\n        {\n          value: TAB_DATA,\n          label: \"Storage\",\n        },\n        ...headersTab,\n        ...authTab,\n        {\n          value: TAB_DNS,\n          label: \"DNS\",\n          rightSlot:\n            workspace.settingDnsOverrides.length > 0 ? (\n              <CountBadge count={workspace.settingDnsOverrides.length} />\n            ) : null,\n        },\n      ]}\n      storageKey=\"workspace_settings_tabs\"\n    >\n      <TabContent value={TAB_AUTH} className=\"overflow-y-auto h-full px-4\">\n        <HttpAuthenticationEditor model={workspace} />\n      </TabContent>\n      <TabContent value={TAB_HEADERS} className=\"overflow-y-auto h-full px-4\">\n        <HeadersEditor\n          inheritedHeaders={inheritedHeaders}\n          inheritedHeadersLabel=\"Defaults\"\n          forceUpdateKey={workspace.id}\n          headers={workspace.headers}\n          onChange={(headers) => patchModel(workspace, { headers })}\n          stateKey={`headers.${workspace.id}`}\n        />\n      </TabContent>\n      <TabContent value={TAB_GENERAL} className=\"overflow-y-auto h-full px-4\">\n        <div className=\"grid grid-rows-[auto_minmax(0,1fr)_auto] gap-4 pb-3 h-full\">\n          <PlainInput\n            required\n            hideLabel\n            placeholder=\"Workspace Name\"\n            label=\"Name\"\n            defaultValue={workspace.name}\n            className=\"!text-base font-sans\"\n            onChange={(name) => patchModel(workspace, { name })}\n          />\n\n          <MarkdownEditor\n            name=\"workspace-description\"\n            placeholder=\"Workspace description\"\n            className=\"border border-border px-2\"\n            defaultValue={workspace.description}\n            stateKey={`description.${workspace.id}`}\n            onChange={(description) => patchModel(workspace, { description })}\n            heightMode=\"auto\"\n          />\n\n          <HStack alignItems=\"center\" justifyContent=\"between\" className=\"w-full\">\n            <Button\n              onClick={async () => {\n                const didDelete = await deleteModelWithConfirm(workspace, {\n                  confirmName: workspace.name,\n                });\n                if (didDelete) {\n                  hide(); // Only hide if actually deleted workspace\n                  await router.navigate({ to: \"/\" });\n                }\n              }}\n              color=\"danger\"\n              variant=\"border\"\n              size=\"xs\"\n            >\n              Delete Workspace\n            </Button>\n            <InlineCode className=\"flex gap-1 items-center text-primary pl-2.5\">\n              {workspaceId}\n              <CopyIconButton\n                className=\"opacity-70 !text-primary\"\n                size=\"2xs\"\n                iconSize=\"sm\"\n                title=\"Copy workspace ID\"\n                text={workspaceId}\n              />\n            </InlineCode>\n          </HStack>\n        </div>\n      </TabContent>\n      <TabContent value={TAB_DATA} className=\"overflow-y-auto h-full px-4\">\n        <VStack space={4} alignItems=\"start\" className=\"pb-3 h-full\">\n          <SyncToFilesystemSetting\n            value={{ filePath: workspaceMeta.settingSyncDir }}\n            onCreateNewWorkspace={hide}\n            onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}\n          />\n          <WorkspaceEncryptionSetting size=\"xs\" />\n        </VStack>\n      </TabContent>\n      <TabContent value={TAB_DNS} className=\"overflow-y-auto h-full px-4\">\n        <DnsOverridesEditor workspace={workspace} />\n      </TabContent>\n    </Tabs>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Alert.tsx",
    "content": "import type { ReactNode } from \"react\";\nimport { Button } from \"./Button\";\nimport { HStack, VStack } from \"./Stacks\";\n\nexport interface AlertProps {\n  onHide: () => void;\n  body: ReactNode;\n}\n\nexport function Alert({ onHide, body }: AlertProps) {\n  return (\n    <VStack space={3} className=\"pb-4\">\n      <div>{body}</div>\n      <HStack space={2} justifyContent=\"end\">\n        <Button className=\"focus\" color=\"primary\" onClick={onHide}>\n          Okay\n        </Button>\n      </HStack>\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/AutoScroller.tsx",
    "content": "import { useVirtualizer, type Virtualizer } from \"@tanstack/react-virtual\";\nimport type { ReactElement, ReactNode, UIEvent } from \"react\";\nimport { useCallback, useLayoutEffect, useRef, useState } from \"react\";\nimport { IconButton } from \"./IconButton\";\n\ninterface Props<T> {\n  data: T[];\n  render: (item: T, index: number) => ReactElement<HTMLElement>;\n  header?: ReactNode;\n  /** Make container focusable for keyboard navigation */\n  focusable?: boolean;\n  /** Callback to expose the virtualizer for keyboard navigation */\n  onVirtualizerReady?: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void;\n}\n\nexport function AutoScroller<T>({\n  data,\n  render,\n  header,\n  focusable = false,\n  onVirtualizerReady,\n}: Props<T>) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [autoScroll, setAutoScroll] = useState<boolean>(true);\n\n  // The virtualizer\n  const rowVirtualizer = useVirtualizer({\n    count: data.length,\n    getScrollElement: () => containerRef.current,\n    estimateSize: () => 27, // react-virtual requires a height, so we'll give it one\n  });\n\n  // Expose virtualizer to parent for keyboard navigation\n  useLayoutEffect(() => {\n    onVirtualizerReady?.(rowVirtualizer);\n  }, [rowVirtualizer, onVirtualizerReady]);\n\n  // Scroll to new items\n  const handleScroll = useCallback(\n    (e: UIEvent<HTMLDivElement>) => {\n      const el = e.currentTarget;\n\n      // Set auto-scroll when container is scrolled\n      const pixelsFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight);\n      const newAutoScroll = pixelsFromBottom <= 0;\n      if (newAutoScroll !== autoScroll) {\n        setAutoScroll(newAutoScroll);\n      }\n    },\n    [autoScroll],\n  );\n\n  // Scroll to bottom on count change\n  useLayoutEffect(() => {\n    if (!autoScroll) return;\n\n    void data.length; // Trigger refresh when length changes\n\n    const el = containerRef.current;\n    if (el == null) return;\n\n    el.scrollTop = el.scrollHeight;\n  }, [autoScroll, data.length]);\n\n  return (\n    <div className=\"h-full w-full relative grid grid-rows-[auto_minmax(0,1fr)]\">\n      {!autoScroll && (\n        <div className=\"absolute bottom-0 right-0 m-2\">\n          <IconButton\n            title=\"Lock scroll to bottom\"\n            icon=\"arrow_down\"\n            size=\"sm\"\n            iconSize=\"md\"\n            variant=\"border\"\n            className=\"!bg-surface z-10\"\n            onClick={() => setAutoScroll((v) => !v)}\n          />\n        </div>\n      )}\n      {header ?? <span aria-hidden />}\n      <div\n        ref={containerRef}\n        className=\"h-full w-full overflow-y-auto focus:outline-none\"\n        onScroll={handleScroll}\n        tabIndex={focusable ? 0 : undefined}\n      >\n        <div\n          style={{\n            height: `${rowVirtualizer.getTotalSize()}px`,\n            width: \"100%\",\n            position: \"relative\",\n          }}\n        >\n          {rowVirtualizer.getVirtualItems().map((virtualItem) => {\n            const item = data[virtualItem.index];\n            return (\n              item != null && (\n                <div\n                  key={virtualItem.key}\n                  style={{\n                    position: \"absolute\",\n                    top: 0,\n                    left: 0,\n                    width: \"100%\",\n                    height: `${virtualItem.size}px`,\n                    transform: `translateY(${virtualItem.start}px)`,\n                  }}\n                >\n                  {render(item, virtualItem.index)}\n                </div>\n              )\n            );\n          })}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Banner.tsx",
    "content": "import classNames from \"classnames\";\nimport type { ReactNode } from \"react\";\n\nexport interface BannerProps {\n  children: ReactNode;\n  className?: string;\n  color?: \"primary\" | \"secondary\" | \"success\" | \"notice\" | \"warning\" | \"danger\" | \"info\";\n}\n\nexport function Banner({ children, className, color }: BannerProps) {\n  return (\n    <div className=\"w-auto grid grid-rows-1 max-h-full flex-0\">\n      <div\n        className={classNames(\n          className,\n          color && \"bg-surface\",\n          `x-theme-banner--${color}`,\n          \"border border-border border-dashed\",\n          \"px-4 py-2 rounded-lg select-auto cursor-auto\",\n          \"overflow-auto text-text\",\n          \"mb-auto\", // Don't stretch all the way down if the parent is in grid or flexbox\n        )}\n      >\n        {children}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/BulkPairEditor.tsx",
    "content": "import { useCallback, useMemo } from \"react\";\nimport { generateId } from \"../../lib/generateId\";\nimport { Editor } from \"./Editor/LazyEditor\";\nimport type { Pair, PairEditorProps, PairWithId } from \"./PairEditor\";\n\ntype Props = PairEditorProps;\n\nexport function BulkPairEditor({\n  pairs,\n  onChange,\n  namePlaceholder,\n  valuePlaceholder,\n  forceUpdateKey,\n  forcedEnvironmentId,\n  stateKey,\n}: Props) {\n  const pairsText = useMemo(() => {\n    return pairs\n      .filter((p) => !(p.name.trim() === \"\" && p.value.trim() === \"\"))\n      .map(pairToLine)\n      .join(\"\\n\");\n  }, [pairs]);\n\n  const handleChange = useCallback(\n    (text: string) => {\n      const pairs = text\n        .split(\"\\n\")\n        .filter((l: string) => l.trim())\n        .map(lineToPair);\n      onChange(pairs);\n    },\n    [onChange],\n  );\n\n  return (\n    <Editor\n      autocompleteFunctions\n      autocompleteVariables\n      stateKey={`bulk_pair.${stateKey}`}\n      forcedEnvironmentId={forcedEnvironmentId}\n      forceUpdateKey={forceUpdateKey}\n      placeholder={`${namePlaceholder ?? \"name\"}: ${valuePlaceholder ?? \"value\"}`}\n      defaultValue={pairsText}\n      language=\"pairs\"\n      onChange={handleChange}\n    />\n  );\n}\n\nfunction pairToLine(pair: Pair) {\n  const value = pair.value.replaceAll(\"\\n\", \"\\\\n\");\n  return `${pair.name}: ${value}`;\n}\n\nfunction lineToPair(line: string): PairWithId {\n  const [, name, value] = line.match(/^(:?[^:]+):\\s+(.*)$/) ?? [];\n  return {\n    enabled: true,\n    name: (name ?? \"\").trim(),\n    value: (value ?? \"\").replaceAll(\"\\\\n\", \"\\n\").trim(),\n    id: generateId(),\n  };\n}\n"
  },
  {
    "path": "src-web/components/core/Button.tsx",
    "content": "import type { Color } from \"@yaakapp-internal/plugins\";\nimport classNames from \"classnames\";\nimport type { HTMLAttributes, ReactNode } from \"react\";\nimport { forwardRef, useImperativeHandle, useRef } from \"react\";\nimport type { HotkeyAction } from \"../../hooks/useHotKey\";\nimport { useFormattedHotkey, useHotKey } from \"../../hooks/useHotKey\";\nimport { Icon } from \"./Icon\";\nimport { LoadingIcon } from \"./LoadingIcon\";\n\nexport type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, \"color\" | \"onChange\"> & {\n  innerClassName?: string;\n  color?: Color | \"custom\" | \"default\";\n  variant?: \"border\" | \"solid\";\n  isLoading?: boolean;\n  size?: \"2xs\" | \"xs\" | \"sm\" | \"md\" | \"auto\";\n  justify?: \"start\" | \"center\";\n  type?: \"button\" | \"submit\";\n  forDropdown?: boolean;\n  disabled?: boolean;\n  title?: string;\n  leftSlot?: ReactNode;\n  rightSlot?: ReactNode;\n  hotkeyAction?: HotkeyAction;\n  hotkeyLabelOnly?: boolean;\n  hotkeyPriority?: number;\n};\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(\n  {\n    isLoading,\n    className,\n    innerClassName,\n    children,\n    forDropdown,\n    color = \"default\",\n    type = \"button\",\n    justify = \"center\",\n    size = \"md\",\n    variant = \"solid\",\n    leftSlot,\n    rightSlot,\n    disabled,\n    hotkeyAction,\n    hotkeyPriority,\n    hotkeyLabelOnly,\n    title,\n    onClick,\n    ...props\n  }: ButtonProps,\n  ref,\n) {\n  const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join(\"\");\n  const fullTitle = hotkeyTrigger ? `${title ?? \"\"} ${hotkeyTrigger}`.trim() : title;\n\n  if (isLoading) {\n    disabled = true;\n  }\n\n  const classes = classNames(\n    className,\n    \"x-theme-button\",\n    `x-theme-button--${variant}`,\n    `x-theme-button--${variant}--${color}`,\n    \"border\", // They all have borders to ensure the same width\n    \"max-w-full min-w-0\", // Help with truncation\n    \"hocus:opacity-100\", // Force opacity for certain hover effects\n    \"whitespace-nowrap outline-none\",\n    \"flex-shrink-0 flex items-center\",\n    \"outline-0\",\n    disabled ? \"pointer-events-none opacity-disabled\" : \"pointer-events-auto\",\n    justify === \"start\" && \"justify-start\",\n    justify === \"center\" && \"justify-center\",\n    size === \"md\" && \"h-md px-3 rounded-md\",\n    size === \"sm\" && \"h-sm px-2.5 rounded-md\",\n    size === \"xs\" && \"h-xs px-2 text-sm rounded-md\",\n    size === \"2xs\" && \"h-2xs px-2 text-xs rounded\",\n\n    // Solids\n    variant === \"solid\" && \"border-transparent\",\n    variant === \"solid\" && color === \"custom\" && \"focus-visible:outline-2 outline-border-focus\",\n    variant === \"solid\" &&\n      color !== \"custom\" &&\n      \"text-text enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle\",\n    variant === \"solid\" && color !== \"custom\" && color !== \"default\" && \"bg-surface\",\n\n    // Borders\n    variant === \"border\" && \"border\",\n    variant === \"border\" &&\n      color !== \"custom\" &&\n      \"border-border-subtle text-text-subtle enabled:hocus:border-border \" +\n        \"enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler\",\n  );\n\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  useImperativeHandle<HTMLButtonElement | null, HTMLButtonElement | null>(\n    ref,\n    () => buttonRef.current,\n  );\n\n  useHotKey(\n    hotkeyAction ?? null,\n    () => {\n      buttonRef.current?.click();\n    },\n    { priority: hotkeyPriority, enable: !hotkeyLabelOnly },\n  );\n\n  return (\n    <button\n      ref={buttonRef}\n      type={type}\n      className={classes}\n      disabled={disabled}\n      onClick={onClick}\n      onDoubleClick={(e) => {\n        // Kind of a hack? This prevents double-clicks from going through buttons. For example, when\n        // double-clicking the workspace header to toggle window maximization\n        e.stopPropagation();\n      }}\n      title={fullTitle}\n      {...props}\n    >\n      {isLoading ? (\n        <LoadingIcon size={size === \"auto\" ? \"md\" : size} className=\"mr-1\" />\n      ) : leftSlot ? (\n        <div className=\"mr-2\">{leftSlot}</div>\n      ) : null}\n      <div\n        className={classNames(\n          \"truncate w-full\",\n          justify === \"start\" ? \"text-left\" : \"text-center\",\n          innerClassName,\n        )}\n      >\n        {children}\n      </div>\n      {rightSlot && <div className=\"ml-1\">{rightSlot}</div>}\n      {forDropdown && (\n        <Icon\n          icon=\"chevron_down\"\n          size={size === \"auto\" ? \"md\" : size}\n          className=\"ml-1 -mr-1 relative top-[0.1em]\"\n        />\n      )}\n    </button>\n  );\n});\n"
  },
  {
    "path": "src-web/components/core/ButtonInfiniteLoading.tsx",
    "content": "import { useState } from \"react\";\nimport type { ButtonProps } from \"./Button\";\nimport { Button } from \"./Button\";\n\nexport function ButtonInfiniteLoading({\n  onClick,\n  isLoading,\n  loadingChildren,\n  children,\n  ...props\n}: ButtonProps & { loadingChildren?: string }) {\n  const [localIsLoading, setLocalIsLoading] = useState<boolean>(false);\n  return (\n    <Button\n      isLoading={localIsLoading || isLoading}\n      onClick={(e) => {\n        setLocalIsLoading(true);\n        onClick?.(e);\n      }}\n      {...props}\n    >\n      {localIsLoading ? (loadingChildren ?? children) : children}\n    </Button>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Checkbox.tsx",
    "content": "import classNames from \"classnames\";\nimport type { ReactNode } from \"react\";\nimport { Icon } from \"./Icon\";\nimport { IconTooltip } from \"./IconTooltip\";\nimport { HStack } from \"./Stacks\";\n\nexport interface CheckboxProps {\n  checked: boolean | \"indeterminate\";\n  title: ReactNode;\n  onChange: (checked: boolean) => void;\n  className?: string;\n  disabled?: boolean;\n  inputWrapperClassName?: string;\n  hideLabel?: boolean;\n  fullWidth?: boolean;\n  help?: ReactNode;\n}\n\nexport function Checkbox({\n  checked,\n  onChange,\n  className,\n  inputWrapperClassName,\n  disabled,\n  title,\n  hideLabel,\n  fullWidth,\n  help,\n}: CheckboxProps) {\n  return (\n    <HStack\n      as=\"label\"\n      alignItems=\"center\"\n      space={2}\n      className={classNames(className, \"text-text mr-auto\")}\n    >\n      <div className={classNames(inputWrapperClassName, \"x-theme-input\", \"relative flex mr-0.5\")}>\n        <input\n          aria-hidden\n          className={classNames(\n            \"appearance-none w-4 h-4 flex-shrink-0 border border-border\",\n            \"rounded outline-none ring-0\",\n            !disabled && \"hocus:border-border-focus hocus:bg-focus/[5%]\",\n            disabled && \"border-dotted\",\n          )}\n          type=\"checkbox\"\n          disabled={disabled}\n          onChange={() => {\n            onChange(checked === \"indeterminate\" ? true : !checked);\n          }}\n        />\n        <div className=\"absolute inset-0 flex items-center justify-center\">\n          <Icon\n            size=\"sm\"\n            className={classNames(disabled && \"opacity-disabled\")}\n            icon={checked === \"indeterminate\" ? \"minus\" : checked ? \"check\" : \"empty\"}\n          />\n        </div>\n      </div>\n      {!hideLabel && (\n        <div\n          className={classNames(\"text-sm\", fullWidth && \"w-full\", disabled && \"opacity-disabled\")}\n        >\n          {title}\n        </div>\n      )}\n      {help && <IconTooltip content={help} />}\n    </HStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/ColorPicker.tsx",
    "content": "import classNames from \"classnames\";\nimport { useState } from \"react\";\nimport { HexColorPicker } from \"react-colorful\";\nimport { useRandomKey } from \"../../hooks/useRandomKey\";\nimport { Icon } from \"./Icon\";\nimport { PlainInput } from \"./PlainInput\";\n\ninterface Props {\n  onChange: (value: string | null) => void;\n  color: string | null;\n  className?: string;\n}\n\nexport function ColorPicker({ onChange, color, className }: Props) {\n  const [updateKey, regenerateKey] = useRandomKey();\n  return (\n    <div className={className}>\n      <HexColorPicker\n        color={color ?? undefined}\n        className=\"!w-full\"\n        onChange={(color) => {\n          onChange(color);\n          regenerateKey(); // To force input to change\n        }}\n      />\n      <PlainInput\n        hideLabel\n        label=\"Plain Color\"\n        forceUpdateKey={updateKey}\n        defaultValue={color ?? \"\"}\n        onChange={onChange}\n        validate={(color) => color.match(/#[0-9a-fA-F]{6}$/) !== null}\n      />\n    </div>\n  );\n}\n\nconst colors = [\n  null,\n  \"danger\",\n  \"warning\",\n  \"notice\",\n  \"success\",\n  \"primary\",\n  \"info\",\n  \"secondary\",\n  \"custom\",\n] as const;\n\nexport function ColorPickerWithThemeColors({ onChange, color, className }: Props) {\n  const [updateKey, regenerateKey] = useRandomKey();\n  const [selectedColor, setSelectedColor] = useState<string | null>(() => {\n    if (color == null) return null;\n    const c = color?.match(/var\\(--([a-z]+)\\)/)?.[1];\n    return c ?? \"custom\";\n  });\n  return (\n    <div className={classNames(className, \"flex flex-col gap-3\")}>\n      <div className=\"flex items-center gap-2.5\">\n        {colors.map((color) => (\n          <button\n            type=\"button\"\n            key={color}\n            onClick={() => {\n              setSelectedColor(color);\n              if (color == null) {\n                onChange(null);\n              } else if (color === \"custom\") {\n                onChange(\"#ffffff\");\n              } else {\n                onChange(`var(--${color})`);\n              }\n            }}\n            className={classNames(\n              \"flex items-center justify-center\",\n              \"w-8 h-8 rounded-full transition-all\",\n              selectedColor === color && \"scale-[1.15]\",\n              selectedColor === color ? \"opacity-100\" : \"opacity-60\",\n              color === null && \"border border-text-subtle\",\n              color === \"primary\" && \"bg-primary\",\n              color === \"secondary\" && \"bg-secondary\",\n              color === \"success\" && \"bg-success\",\n              color === \"notice\" && \"bg-notice\",\n              color === \"warning\" && \"bg-warning\",\n              color === \"danger\" && \"bg-danger\",\n              color === \"info\" && \"bg-info\",\n              color === \"custom\" &&\n                \"bg-[conic-gradient(var(--danger),var(--warning),var(--notice),var(--success),var(--info),var(--primary),var(--danger))]\",\n            )}\n          >\n            {color == null && <Icon icon=\"minus\" className=\"text-text-subtle\" size=\"md\" />}\n          </button>\n        ))}\n      </div>\n      {selectedColor === \"custom\" && (\n        <>\n          <HexColorPicker\n            color={color ?? undefined}\n            className=\"!w-full\"\n            onChange={(color) => {\n              onChange(color);\n              regenerateKey(); // To force input to change\n            }}\n          />\n          <PlainInput\n            hideLabel\n            label=\"Plain Color\"\n            forceUpdateKey={updateKey}\n            defaultValue={color ?? \"\"}\n            onChange={onChange}\n            validate={(color) => color.match(/#[0-9a-fA-F]{6}$/) !== null}\n          />\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Confirm.tsx",
    "content": "import type { Color } from \"@yaakapp-internal/plugins\";\nimport type { FormEvent } from \"react\";\nimport { useState } from \"react\";\nimport { CopyIconButton } from \"../CopyIconButton\";\nimport { Button } from \"./Button\";\nimport { PlainInput } from \"./PlainInput\";\nimport { HStack } from \"./Stacks\";\n\nexport interface ConfirmProps {\n  onHide: () => void;\n  onResult: (result: boolean) => void;\n  confirmText?: string;\n  requireTyping?: string;\n  color?: Color;\n}\n\nexport function Confirm({\n  onHide,\n  onResult,\n  confirmText,\n  requireTyping,\n  color = \"primary\",\n}: ConfirmProps) {\n  const [confirm, setConfirm] = useState<string>(\"\");\n  const handleHide = () => {\n    onResult(false);\n    onHide();\n  };\n\n  const didConfirm = !requireTyping || confirm === requireTyping;\n\n  const handleSuccess = (e: FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    if (didConfirm) {\n      onResult(true);\n      onHide();\n    }\n  };\n\n  return (\n    <form className=\"flex flex-col\" onSubmit={handleSuccess}>\n      {requireTyping && (\n        <PlainInput\n          autoFocus\n          onChange={setConfirm}\n          placeholder={requireTyping}\n          labelRightSlot={\n            <CopyIconButton\n              tabIndex={-1}\n              text={requireTyping}\n              title=\"Copy name\"\n              className=\"text-text-subtlest\"\n              iconSize=\"sm\"\n              size=\"2xs\"\n            />\n          }\n          label={\n            <>\n              Type <strong>{requireTyping}</strong> to confirm\n            </>\n          }\n        />\n      )}\n      <HStack space={2} justifyContent=\"start\" className=\"mt-2 mb-4 flex-row-reverse\">\n        <Button type=\"submit\" color={color} disabled={!didConfirm}>\n          {confirmText ?? \"Confirm\"}\n        </Button>\n        <Button onClick={handleHide} variant=\"border\">\n          Cancel\n        </Button>\n      </HStack>\n    </form>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/CountBadge.tsx",
    "content": "import type { Color } from \"@yaakapp-internal/plugins\";\nimport classNames from \"classnames\";\n\ninterface Props {\n  count: number | true;\n  count2?: number | true;\n  className?: string;\n  color?: Color;\n  showZero?: boolean;\n}\n\nexport function CountBadge({ count, count2, className, color, showZero }: Props) {\n  if (count === 0 && !showZero) return null;\n\n  return (\n    <div\n      aria-hidden\n      className={classNames(\n        className,\n        \"flex items-center\",\n        \"opacity-70 border text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono\",\n        color == null && \"border-border-subtle\",\n        color === \"primary\" && \"text-primary\",\n        color === \"secondary\" && \"text-secondary\",\n        color === \"success\" && \"text-success\",\n        color === \"notice\" && \"text-notice\",\n        color === \"warning\" && \"text-warning\",\n        color === \"danger\" && \"text-danger\",\n      )}\n    >\n      {count === true ? (\n        <div aria-hidden className=\"rounded-full h-1 w-1 bg-[currentColor]\" />\n      ) : (\n        count\n      )}\n      {count2 != null && (\n        <>\n          /\n          {count2 === true ? (\n            <div aria-hidden className=\"rounded-full h-1 w-1 bg-[currentColor]\" />\n          ) : (\n            count2\n          )}\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/DetailsBanner.tsx",
    "content": "import classNames from \"classnames\";\nimport { atom, useAtom } from \"jotai\";\nimport type { HTMLAttributes, ReactNode } from \"react\";\nimport { useMemo } from \"react\";\nimport { atomWithKVStorage } from \"../../lib/atoms/atomWithKVStorage\";\nimport type { BannerProps } from \"./Banner\";\nimport { Banner } from \"./Banner\";\n\ninterface Props extends HTMLAttributes<HTMLDetailsElement> {\n  summary: ReactNode;\n  color?: BannerProps[\"color\"];\n  defaultOpen?: boolean;\n  storageKey?: string;\n}\n\nexport function DetailsBanner({\n  className,\n  color,\n  summary,\n  children,\n  defaultOpen,\n  storageKey,\n  ...extraProps\n}: Props) {\n  // oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when storageKey changes\n  const openAtom = useMemo(\n    () =>\n      storageKey\n        ? atomWithKVStorage<boolean>([\"details_banner\", storageKey], defaultOpen ?? false)\n        : atom(defaultOpen ?? false),\n    [storageKey],\n  );\n\n  const [isOpen, setIsOpen] = useAtom(openAtom);\n\n  const handleToggle = (e: React.SyntheticEvent<HTMLDetailsElement>) => {\n    if (storageKey) {\n      setIsOpen(e.currentTarget.open);\n    }\n  };\n\n  return (\n    <Banner color={color} className={className}>\n      <details className=\"group list-none\" open={isOpen} onToggle={handleToggle} {...extraProps}>\n        <summary className=\"!cursor-default !select-none list-none flex items-center gap-3 focus:outline-none opacity-70\">\n          <div\n            className={classNames(\n              \"transition-transform\",\n              \"group-open:rotate-90\",\n              \"w-0 h-0 border-t-[0.3em] border-b-[0.3em] border-l-[0.5em] border-r-0\",\n              \"border-t-transparent border-b-transparent border-l-text-subtle\",\n            )}\n          />\n          {summary}\n        </summary>\n        <div className=\"mt-1.5 pb-2\">{children}</div>\n      </details>\n    </Banner>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Dialog.tsx",
    "content": "import classNames from \"classnames\";\nimport * as m from \"motion/react-m\";\nimport type { ReactNode } from \"react\";\nimport { useMemo } from \"react\";\nimport { Overlay } from \"../Overlay\";\nimport { Heading } from \"./Heading\";\nimport { IconButton } from \"./IconButton\";\nimport type { DialogSize } from \"@yaakapp-internal/plugins\";\n\nexport interface DialogProps {\n  children: ReactNode;\n  open: boolean;\n  onClose?: () => void;\n  disableBackdropClose?: boolean;\n  title?: ReactNode;\n  description?: ReactNode;\n  className?: string;\n  size?: DialogSize;\n  hideX?: boolean;\n  noPadding?: boolean;\n  noScroll?: boolean;\n  vAlign?: \"top\" | \"center\";\n}\n\nexport function Dialog({\n  children,\n  className,\n  size = \"full\",\n  open,\n  onClose,\n  disableBackdropClose,\n  title,\n  description,\n  hideX,\n  noPadding,\n  noScroll,\n  vAlign = \"center\",\n}: DialogProps) {\n  const titleId = useMemo(() => Math.random().toString(36).slice(2), []);\n  const descriptionId = useMemo(\n    () => (description ? Math.random().toString(36).slice(2) : undefined),\n    [description],\n  );\n\n  return (\n    <Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName=\"dialog\">\n      <div\n        role=\"dialog\"\n        className={classNames(\n          \"py-4 x-theme-dialog absolute inset-0 pointer-events-none\",\n          \"h-full flex flex-col items-center justify-center\",\n          vAlign === \"top\" && \"justify-start\",\n          vAlign === \"center\" && \"justify-center\",\n        )}\n        aria-labelledby={titleId}\n        aria-describedby={descriptionId}\n        tabIndex={-1}\n        onKeyDown={(e) => {\n          // NOTE: We handle Escape on the element itself so that it doesn't close multiple\n          //   dialogs and can be intercepted by children if needed.\n          if (e.key === \"Escape\") {\n            onClose?.();\n            e.stopPropagation();\n            e.preventDefault();\n          }\n        }}\n      >\n        <m.div\n          initial={{ top: 5, scale: 0.97 }}\n          animate={{ top: 0, scale: 1 }}\n          className={classNames(\n            className,\n            \"grid grid-rows-[auto_auto_minmax(0,1fr)]\",\n            \"grid-cols-1\", // must be here for inline code blocks to correctly break words\n            \"relative bg-surface pointer-events-auto\",\n            \"rounded-lg\",\n            \"border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]\",\n            \"min-h-[10rem]\",\n            \"max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]\",\n            size === \"sm\" && \"w-[30rem]\",\n            size === \"md\" && \"w-[50rem]\",\n            size === \"lg\" && \"w-[70rem]\",\n            size === \"full\" && \"w-[100vw] h-[100vh]\",\n            size === \"dynamic\" && \"min-w-[20rem] max-w-[100vw]\",\n          )}\n        >\n          {title ? (\n            <Heading className=\"px-6 mt-4 mb-2\" level={1} id={titleId}>\n              {title}\n            </Heading>\n          ) : (\n            <span />\n          )}\n\n          {description ? (\n            <div className=\"min-h-0 px-6 text-text-subtle mb-3\" id={descriptionId}>\n              {description}\n            </div>\n          ) : (\n            <span />\n          )}\n\n          <div\n            className={classNames(\n              \"h-full w-full grid grid-cols-[minmax(0,1fr)] grid-rows-1\",\n              !noPadding && \"px-6 py-2\",\n              !noScroll && \"overflow-y-auto overflow-x-hidden\",\n            )}\n          >\n            {children}\n          </div>\n\n          {/*Put close at the end so that it's the last thing to be tabbed to*/}\n          {!hideX && (\n            <div className=\"ml-auto absolute right-1 top-1\">\n              <IconButton\n                className=\"opacity-70 hover:opacity-100\"\n                onClick={onClose}\n                title=\"Close dialog (Esc)\"\n                aria-label=\"Close\"\n                size=\"sm\"\n                icon=\"x\"\n              />\n            </div>\n          )}\n        </m.div>\n      </div>\n    </Overlay>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/DismissibleBanner.tsx",
    "content": "import type { Color } from \"@yaakapp-internal/plugins\";\nimport classNames from \"classnames\";\nimport { useKeyValue } from \"../../hooks/useKeyValue\";\nimport type { BannerProps } from \"./Banner\";\nimport { Banner } from \"./Banner\";\nimport { Button } from \"./Button\";\nimport { HStack } from \"./Stacks\";\n\nexport function DismissibleBanner({\n  children,\n  className,\n  id,\n  actions,\n  ...props\n}: BannerProps & {\n  id: string;\n  actions?: { label: string; onClick: () => void; color?: Color }[];\n}) {\n  const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({\n    namespace: \"global\",\n    key: [\"dismiss-banner\", id],\n    fallback: false,\n  });\n\n  if (dismissed) return null;\n\n  return (\n    <Banner\n      className={classNames(className, \"relative grid grid-cols-[1fr_auto] gap-3\")}\n      {...props}\n    >\n      {children}\n      <HStack space={1.5}>\n        {actions?.map((a) => (\n          <Button\n            key={a.label}\n            variant=\"border\"\n            color={a.color ?? props.color}\n            size=\"xs\"\n            onClick={a.onClick}\n            title={a.label}\n          >\n            {a.label}\n          </Button>\n        ))}\n        <Button\n          variant=\"border\"\n          color={props.color}\n          size=\"xs\"\n          onClick={() => setDismissed((d) => !d)}\n          title=\"Dismiss message\"\n        >\n          Dismiss\n        </Button>\n      </HStack>\n    </Banner>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Dropdown.tsx",
    "content": "import classNames from \"classnames\";\nimport { atom } from \"jotai\";\nimport * as m from \"motion/react-m\";\nimport type {\n  CSSProperties,\n  HTMLAttributes,\n  MouseEvent,\n  ReactElement,\n  FocusEvent as ReactFocusEvent,\n  KeyboardEvent as ReactKeyboardEvent,\n  ReactNode,\n  RefObject,\n  SetStateAction,\n} from \"react\";\nimport {\n  Children,\n  cloneElement,\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useKey, useWindowSize } from \"react-use\";\nimport { useClickOutside } from \"../../hooks/useClickOutside\";\nimport { fireAndForget } from \"../../lib/fireAndForget\";\nimport type { HotkeyAction } from \"../../hooks/useHotKey\";\nimport { useHotKey } from \"../../hooks/useHotKey\";\nimport { useStateWithDeps } from \"../../hooks/useStateWithDeps\";\nimport { generateId } from \"../../lib/generateId\";\nimport { getNodeText } from \"../../lib/getNodeText\";\nimport { jotaiStore } from \"../../lib/jotai\";\nimport { ErrorBoundary } from \"../ErrorBoundary\";\nimport { Overlay } from \"../Overlay\";\nimport { Button } from \"./Button\";\nimport { Hotkey } from \"./Hotkey\";\nimport { Icon, type IconProps } from \"./Icon\";\nimport { LoadingIcon } from \"./LoadingIcon\";\nimport { Separator } from \"./Separator\";\nimport { HStack, VStack } from \"./Stacks\";\n\nexport type DropdownItemSeparator = {\n  type: \"separator\";\n  label?: ReactNode;\n  hidden?: boolean;\n};\n\nexport type DropdownItemContent = {\n  type: \"content\";\n  label?: ReactNode;\n  hidden?: boolean;\n};\n\nexport type DropdownItemDefault = {\n  type?: \"default\";\n  label: ReactNode;\n  hotKeyAction?: HotkeyAction;\n  hotKeyLabelOnly?: boolean;\n  color?: \"default\" | \"primary\" | \"danger\" | \"info\" | \"warning\" | \"notice\" | \"success\";\n  disabled?: boolean;\n  hidden?: boolean;\n  leftSlot?: ReactNode;\n  rightSlot?: ReactNode;\n  waitForOnSelect?: boolean;\n  keepOpenOnSelect?: boolean;\n  onSelect?: () => void | Promise<void>;\n  submenu?: DropdownItem[];\n  /** If true, submenu opens on click instead of hover */\n  submenuOpenOnClick?: boolean;\n  icon?: IconProps[\"icon\"];\n};\n\nexport type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent;\n\nexport interface DropdownProps {\n  children: ReactElement<HTMLAttributes<HTMLButtonElement>>;\n  items: DropdownItem[];\n  fullWidth?: boolean;\n  hotKeyAction?: HotkeyAction;\n  onOpen?: () => void;\n}\n\nexport interface DropdownRef {\n  isOpen: boolean;\n  open: (index?: number) => void;\n  toggle: () => void;\n  close?: () => void;\n  next?: (incrBy?: number) => void;\n  prev?: (incrBy?: number) => void;\n  select?: () => void;\n}\n\n// Every dropdown gets a unique ID and we use this global atom to ensure\n// only one dropdown can be open at a time.\n// TODO: Also make ContextMenu use this\nconst openAtom = atom<string | null>(null);\n\nexport const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(\n  { children, items, hotKeyAction, fullWidth, onOpen }: DropdownProps,\n  ref,\n) {\n  const id = useRef(generateId());\n  const [isOpen, setIsOpen] = useState<boolean>(false);\n\n  useEffect(() => {\n    return jotaiStore.sub(openAtom, () => {\n      const globalOpenId = jotaiStore.get(openAtom);\n      const newIsOpen = globalOpenId === id.current;\n      if (newIsOpen !== isOpen) {\n        setIsOpen(newIsOpen);\n      }\n    });\n  }, [isOpen]);\n\n  // const [isOpen, _setIsOpen] = useState<boolean>(false);\n  const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number | null>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const menuRef = useRef<Omit<DropdownRef, \"open\">>(null);\n\n  const handleSetIsOpen = useCallback(\n    (o: SetStateAction<boolean>) => {\n      jotaiStore.set(openAtom, (prevId) => {\n        const prevIsOpen = prevId === id.current;\n        const newIsOpen = typeof o === \"function\" ? o(prevIsOpen) : o;\n        // Persist background color of button until we close the dropdown\n        if (newIsOpen) {\n          onOpen?.();\n          if (buttonRef.current) {\n            buttonRef.current.style.backgroundColor = window\n              .getComputedStyle(buttonRef.current)\n              .getPropertyValue(\"background-color\");\n          }\n        }\n        return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state\n      });\n    },\n    [onOpen],\n  );\n\n  // Because a different dropdown can cause ours to close, a useEffect([isOpen]) is the only method\n  // we have of detecting the dropdown closed, to do cleanup.\n  useEffect(() => {\n    if (!isOpen) {\n      // Clear persisted BG\n      if (buttonRef.current) buttonRef.current.style.backgroundColor = \"\";\n      // Set to different value when opened and closed to force it to update. This is to force\n      // <Menu/> to reset its selected-index state, which it does when this prop changes\n      setDefaultSelectedIndex(null);\n    }\n  }, [isOpen]);\n\n  // Pull into variable so linter forces us to add it as a hook dep to useImperativeHandle. If we don't,\n  // the ref will not update when menuRef updates, causing stale callback state to be used.\n  const menuRefCurrent = menuRef.current;\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      ...menuRefCurrent,\n      isOpen: isOpen,\n      toggle() {\n        if (!isOpen) this.open();\n        else this.close();\n      },\n      open(index?: number) {\n        handleSetIsOpen(true);\n        setDefaultSelectedIndex(index ?? -1);\n      },\n      close() {\n        handleSetIsOpen(false);\n      },\n    }),\n    [isOpen, handleSetIsOpen, menuRefCurrent],\n  );\n\n  useHotKey(hotKeyAction ?? null, () => {\n    setDefaultSelectedIndex(0);\n    handleSetIsOpen(true);\n  });\n\n  const child = useMemo(() => {\n    const existingChild = Children.only(children);\n    const originalOnClick = existingChild.props?.onClick;\n    const props: HTMLAttributes<HTMLButtonElement> & { ref: RefObject<HTMLButtonElement | null> } =\n      {\n        ...existingChild.props,\n        ref: buttonRef,\n        \"aria-haspopup\": \"true\",\n        onClick: (e: MouseEvent<HTMLButtonElement>) => {\n          // Call original onClick first if it exists\n          originalOnClick?.(e);\n\n          // Only toggle dropdown if event wasn't prevented\n          if (!e.defaultPrevented) {\n            e.preventDefault();\n            e.stopPropagation();\n            handleSetIsOpen((o) => !o); // Toggle dropdown\n          }\n        },\n      };\n    return cloneElement(existingChild, props);\n  }, [children, handleSetIsOpen]);\n\n  useEffect(() => {\n    buttonRef.current?.setAttribute(\"aria-expanded\", isOpen.toString());\n  }, [isOpen]);\n\n  const windowSize = useWindowSize();\n  const triggerRect = useMemo(() => {\n    if (!windowSize) return null; // No-op to TS happy with this dep\n    if (!isOpen) return null;\n    return buttonRef.current?.getBoundingClientRect();\n  }, [isOpen, windowSize]);\n\n  return (\n    <>\n      {child}\n      <ErrorBoundary name={\"Dropdown Menu\"}>\n        <Menu\n          ref={menuRef}\n          showTriangle\n          triggerRef={buttonRef}\n          fullWidth={fullWidth}\n          defaultSelectedIndex={defaultSelectedIndex}\n          items={items}\n          triggerShape={triggerRect ?? null}\n          onClose={() => handleSetIsOpen(false)}\n          isOpen={isOpen}\n        />\n      </ErrorBoundary>\n    </>\n  );\n});\n\nexport interface ContextMenuProps {\n  triggerPosition: { x: number; y: number } | null;\n  className?: string;\n  items: DropdownProps[\"items\"];\n  onClose: () => void;\n}\n\nexport const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(\n  { triggerPosition, className, items, onClose },\n  ref,\n) {\n  const triggerShape = useMemo(\n    () => ({\n      top: triggerPosition?.y ?? 0,\n      bottom: triggerPosition?.y ?? 0,\n      left: triggerPosition?.x ?? 0,\n      right: triggerPosition?.x ?? 0,\n    }),\n    [triggerPosition],\n  );\n\n  if (triggerPosition == null) return null;\n\n  return (\n    <Menu\n      isOpen={true} // Always open because we return null if not\n      className={className}\n      defaultSelectedIndex={null}\n      ref={ref}\n      items={items}\n      onClose={onClose}\n      triggerShape={triggerShape}\n    />\n  );\n});\n\ninterface MenuProps {\n  className?: string;\n  defaultSelectedIndex: number | null;\n  triggerShape: Pick<DOMRect, \"top\" | \"bottom\" | \"left\" | \"right\"> | null;\n  onClose: () => void;\n  onCloseAll?: () => void;\n  showTriangle?: boolean;\n  fullWidth?: boolean;\n  isOpen: boolean;\n  items: DropdownItem[];\n  triggerRef?: RefObject<HTMLButtonElement | null>;\n  isSubmenu?: boolean;\n}\n\nconst Menu = forwardRef<Omit<DropdownRef, \"open\" | \"isOpen\" | \"toggle\" | \"items\">, MenuProps>(\n  (\n    {\n      className,\n      isOpen,\n      items,\n      fullWidth,\n      onClose,\n      onCloseAll,\n      triggerShape,\n      defaultSelectedIndex,\n      showTriangle,\n      triggerRef,\n      isSubmenu,\n    }: MenuProps,\n    ref,\n  ) => {\n    const [selectedIndex, setSelectedIndex] = useStateWithDeps<number | null>(\n      defaultSelectedIndex ?? -1,\n      [defaultSelectedIndex],\n    );\n\n    const [filter, setFilter] = useState<string>(\"\");\n\n    // Clear filter when menu opens\n    useEffect(() => {\n      if (isOpen) {\n        setFilter(\"\");\n      }\n    }, [isOpen]);\n\n    const [activeSubmenu, setActiveSubmenu] = useState<{\n      item: DropdownItemDefault;\n      parent: HTMLButtonElement;\n      viaKeyboard?: boolean;\n    } | null>(null);\n\n    const mousePosition = useRef({ x: 0, y: 0 });\n    const submenuTimeoutRef = useRef<number | null>(null);\n    const submenuRef = useRef<HTMLDivElement>(null);\n\n    // HACK: Use a ref to track selectedIndex so our closure functions (eg. select()) can\n    //  have access to the latest value.\n    const selectedIndexRef = useRef(selectedIndex);\n    useEffect(() => {\n      selectedIndexRef.current = selectedIndex;\n    }, [selectedIndex]);\n\n    const handleClose = useCallback(() => {\n      onClose();\n      setActiveSubmenu(null);\n    }, [onClose]);\n\n    // Close the entire menu hierarchy (used when selecting an item)\n    const handleCloseAll = useCallback(() => {\n      if (onCloseAll) {\n        onCloseAll();\n      } else {\n        handleClose();\n      }\n    }, [onCloseAll, handleClose]);\n\n    // Handle type-ahead filtering (only for the deepest open menu)\n    const handleMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n      // Skip if this menu has a submenu open - let the submenu handle typing\n      if (activeSubmenu) return;\n\n      const isCharacter = e.key.length === 1;\n      const isSpecial = e.ctrlKey || e.metaKey || e.altKey;\n      if (isCharacter && !isSpecial) {\n        e.preventDefault();\n        setFilter((f) => f + e.key);\n        setSelectedIndex(0);\n      } else if (e.key === \"Backspace\" && !isSpecial) {\n        e.preventDefault();\n        setFilter((f) => f.slice(0, -1));\n      }\n    };\n\n    useKey(\n      \"Escape\",\n      () => {\n        if (!isOpen) return;\n        if (activeSubmenu) setActiveSubmenu(null);\n        else if (filter !== \"\") setFilter(\"\");\n        else handleClose();\n      },\n      {},\n      [isOpen, filter, setFilter, handleClose, activeSubmenu],\n    );\n\n    const handlePrev = useCallback(\n      (incrBy = 1) => {\n        setSelectedIndex((currIndex) => {\n          let nextIndex = (currIndex ?? 0) - incrBy;\n          const maxTries = items.length;\n          for (let i = 0; i < maxTries; i++) {\n            if (items[nextIndex]?.hidden || items[nextIndex]?.type === \"separator\") {\n              nextIndex--;\n            } else if (nextIndex < 0) {\n              nextIndex = items.length - 1;\n            } else {\n              break;\n            }\n          }\n          return nextIndex;\n        });\n      },\n      [items, setSelectedIndex],\n    );\n\n    const handleNext = useCallback(\n      (incrBy = 1) => {\n        setSelectedIndex((currIndex) => {\n          let nextIndex = (currIndex ?? -1) + incrBy;\n          const maxTries = items.length;\n          for (let i = 0; i < maxTries; i++) {\n            if (items[nextIndex]?.hidden || items[nextIndex]?.type === \"separator\") {\n              nextIndex++;\n            } else if (nextIndex >= items.length) {\n              nextIndex = 0;\n            } else {\n              break;\n            }\n          }\n          return nextIndex;\n        });\n      },\n      [items, setSelectedIndex],\n    );\n\n    // Ensure selection is on a valid item (not hidden/separator/content)\n    useEffect(() => {\n      const item = items[selectedIndex ?? -1];\n      if (item?.hidden || item?.type === \"separator\" || item?.type === \"content\") {\n        handleNext();\n      }\n    }, [selectedIndex, items, handleNext]);\n\n    useKey(\n      \"ArrowUp\",\n      (e) => {\n        if (!isOpen || activeSubmenu) return;\n        e.preventDefault();\n        handlePrev();\n      },\n      {},\n      [isOpen, activeSubmenu],\n    );\n\n    useKey(\n      \"ArrowDown\",\n      (e) => {\n        if (!isOpen || activeSubmenu) return;\n        e.preventDefault();\n        handleNext();\n      },\n      {},\n      [isOpen, activeSubmenu],\n    );\n\n    useKey(\n      \"ArrowLeft\",\n      (e) => {\n        if (!isOpen) return;\n        // Only handle if this menu doesn't have an open submenu\n        // (let the deepest submenu handle the key first)\n        if (activeSubmenu) return;\n        // If this is a submenu, ArrowLeft closes it and returns to parent\n        if (isSubmenu) {\n          e.preventDefault();\n          onClose();\n        }\n      },\n      {},\n      [isOpen, isSubmenu, activeSubmenu, onClose],\n    );\n\n    const handleSelect = useCallback(\n      async (item: DropdownItem, parentEl?: HTMLButtonElement) => {\n        // Handle click-to-open submenu\n        if (\"submenu\" in item && item.submenu && item.submenuOpenOnClick && parentEl) {\n          setActiveSubmenu({ item, parent: parentEl });\n          return;\n        }\n\n        if (!(\"onSelect\" in item) || !item.onSelect) return;\n        setSelectedIndex(null);\n\n        const promise = item.onSelect();\n        if (item.waitForOnSelect) {\n          try {\n            await promise;\n          } catch {\n            // Nothing\n          }\n        }\n\n        if (!item.keepOpenOnSelect) handleCloseAll();\n      },\n      [handleCloseAll, setSelectedIndex],\n    );\n\n    useImperativeHandle(ref, () => {\n      return {\n        close: handleClose,\n        prev: handlePrev,\n        next: handleNext,\n        select: async () => {\n          const item = items[selectedIndexRef.current ?? -1] ?? null;\n          if (!item) return;\n          await handleSelect(item);\n        },\n      };\n    }, [handleClose, handleNext, handlePrev, handleSelect, items]);\n\n    const styles = useMemo<{\n      container: CSSProperties;\n      menu: CSSProperties;\n      triangle: CSSProperties;\n      upsideDown: boolean;\n    }>(() => {\n      if (triggerShape == null) return { container: {}, triangle: {}, menu: {}, upsideDown: false };\n\n      if (isSubmenu) {\n        const parentRect = triggerShape;\n        const docRect = document.documentElement.getBoundingClientRect();\n        const spaceRight = docRect.width - parentRect.right;\n        const spaceBelow = docRect.height - parentRect.top;\n        const spaceAbove = parentRect.bottom;\n        const openLeft = spaceRight < 200; // Heuristic to open on left if not enough space on right\n        // Estimate submenu height (items * ~28px + padding), flip if not enough space below\n        const estimatedHeight = items.length * 28 + 20;\n        const openUpward = spaceBelow < estimatedHeight && spaceAbove > spaceBelow;\n\n        return {\n          upsideDown: openUpward,\n          container: {\n            top: openUpward ? undefined : parentRect.top,\n            bottom: openUpward ? docRect.height - parentRect.bottom : undefined,\n            left: openLeft ? undefined : parentRect.right,\n            right: openLeft ? docRect.width - parentRect.left : undefined,\n          },\n          menu: {\n            maxHeight: `${(openUpward ? spaceAbove : spaceBelow) - 20}px`,\n          },\n          triangle: {}, // No triangle for submenus\n        };\n      }\n\n      const menuMarginY = 5;\n      const docRect = document.documentElement.getBoundingClientRect();\n      const width = triggerShape.right - triggerShape.left;\n      const heightAbove = triggerShape.top;\n      const heightBelow = docRect.height - triggerShape.bottom;\n      const horizontalSpaceRemaining = docRect.width - triggerShape.left;\n      const top = triggerShape.bottom;\n      const onRight = horizontalSpaceRemaining < 300;\n      const upsideDown = heightBelow < heightAbove && heightBelow < items.length * 25 + 20 + 200;\n      const triggerWidth = triggerShape.right - triggerShape.left;\n      return {\n        upsideDown,\n        container: {\n          top: !upsideDown ? top + menuMarginY : undefined,\n          bottom: upsideDown\n            ? docRect.height - top - (triggerShape.top - triggerShape.bottom) + menuMarginY\n            : undefined,\n          right: onRight ? docRect.width - triggerShape.right : undefined,\n          left: !onRight ? triggerShape.left : undefined,\n          minWidth: fullWidth ? triggerWidth : undefined,\n          maxWidth: \"40rem\",\n        },\n        triangle: {\n          width: \"0.4rem\",\n          height: \"0.4rem\",\n          ...(onRight\n            ? { right: width / 2, marginRight: \"-0.2rem\" }\n            : { left: width / 2, marginLeft: \"-0.2rem\" }),\n          ...(upsideDown\n            ? { bottom: \"-0.2rem\", rotate: \"225deg\" }\n            : { top: \"-0.2rem\", rotate: \"45deg\" }),\n        },\n        menu: {\n          maxHeight: `${(upsideDown ? heightAbove : heightBelow) - 15}px`,\n        },\n      };\n    }, [fullWidth, items.length, triggerShape, isSubmenu]);\n\n    const filteredItems = useMemo(\n      () => items.filter((i) => getNodeText(i.label).toLowerCase().includes(filter.toLowerCase())),\n      [items, filter],\n    );\n\n    const handleFocus = useCallback(\n      (i: DropdownItem) => {\n        const index = filteredItems.indexOf(i) ?? null;\n        setSelectedIndex(index);\n      },\n      [filteredItems, setSelectedIndex],\n    );\n\n    useKey(\n      \"ArrowRight\",\n      (e) => {\n        if (!isOpen || activeSubmenu) return;\n        const item = filteredItems[selectedIndex ?? -1];\n        if (item?.type !== \"separator\" && item?.type !== \"content\" && item?.submenu) {\n          e.preventDefault();\n          const parent = document.activeElement as HTMLButtonElement;\n          if (parent) {\n            setActiveSubmenu({ item, parent, viaKeyboard: true });\n          }\n        }\n      },\n      {},\n      [isOpen, activeSubmenu, filteredItems, selectedIndex],\n    );\n\n    useKey(\n      \"Enter\",\n      (e) => {\n        if (!isOpen || activeSubmenu) return;\n        const item = filteredItems[selectedIndex ?? -1];\n        if (!item || item.type === \"separator\" || item.type === \"content\") return;\n        e.preventDefault();\n        if (item.submenu) {\n          const parent = document.activeElement as HTMLButtonElement;\n          if (parent) {\n            setActiveSubmenu({ item, parent, viaKeyboard: true });\n          }\n        } else if (item.onSelect) {\n          fireAndForget(handleSelect(item));\n        }\n      },\n      {},\n      [isOpen, activeSubmenu, filteredItems, selectedIndex, handleSelect],\n    );\n\n    const handleItemHover = useCallback(\n      (item: DropdownItemDefault, parent: HTMLButtonElement) => {\n        if (submenuTimeoutRef.current) {\n          clearTimeout(submenuTimeoutRef.current);\n        }\n\n        if (item.submenu && !item.submenuOpenOnClick) {\n          setActiveSubmenu({ item, parent });\n        } else if (activeSubmenu) {\n          submenuTimeoutRef.current = window.setTimeout(() => {\n            const submenuEl = submenuRef.current;\n            if (!submenuEl || !activeSubmenu) {\n              setActiveSubmenu(null);\n              return;\n            }\n\n            const { parent } = activeSubmenu;\n            const parentRect = parent.getBoundingClientRect();\n            const submenuRect = submenuEl.getBoundingClientRect();\n            const mouse = mousePosition.current;\n\n            if (\n              mouse.x >= submenuRect.left &&\n              mouse.x <= submenuRect.right &&\n              mouse.y >= submenuRect.top &&\n              mouse.y <= submenuRect.bottom\n            ) {\n              return;\n            }\n\n            const tolerance = 5;\n            const p1 = { x: parentRect.right, y: parentRect.top - tolerance };\n            const p2 = { x: parentRect.right, y: parentRect.bottom + tolerance };\n            const p3 = { x: submenuRect.left, y: submenuRect.top - tolerance };\n            const p4 = { x: submenuRect.left, y: submenuRect.bottom + tolerance };\n\n            const inTriangle =\n              isPointInTriangle(mouse, p1, p2, p4) || isPointInTriangle(mouse, p1, p3, p4);\n\n            if (!inTriangle) {\n              setActiveSubmenu(null);\n            }\n          }, 100);\n        }\n      },\n      [activeSubmenu],\n    );\n\n    const menuRef = useRef<HTMLDivElement | null>(null);\n    useClickOutside(menuRef, handleClose, triggerRef);\n\n    // Keep focus on menu container when filtering leaves no items\n    useEffect(() => {\n      if (filteredItems.length === 0 && filter && menuRef.current) {\n        menuRef.current.focus();\n      }\n    }, [filteredItems.length, filter]);\n\n    const submenuTriggerShape = useMemo(() => {\n      if (!activeSubmenu) return null;\n      const rect = activeSubmenu.parent.getBoundingClientRect();\n      return {\n        top: rect.top,\n        bottom: rect.bottom,\n        left: rect.left,\n        right: rect.right,\n      };\n    }, [activeSubmenu]);\n\n    const handleMouseMove = (event: React.MouseEvent) => {\n      mousePosition.current = { x: event.clientX, y: event.clientY };\n    };\n\n    const menuContent = (\n      <m.div\n        ref={menuRef}\n        tabIndex={0}\n        onKeyDown={handleMenuKeyDown}\n        onMouseMove={handleMouseMove}\n        onContextMenu={(e) => {\n          // Prevent showing any ancestor context menus\n          e.stopPropagation();\n          e.preventDefault();\n        }}\n        initial={{ opacity: 0, y: (styles.upsideDown ? 1 : -1) * 5, scale: 0.98 }}\n        animate={{ opacity: 1, y: 0, scale: 1 }}\n        role=\"menu\"\n        aria-orientation=\"vertical\"\n        dir=\"ltr\"\n        style={styles.container}\n        className={classNames(\n          className,\n          \"x-theme-menu\",\n          \"outline-none my-1 pointer-events-auto z-40\",\n          \"fixed\",\n        )}\n      >\n        {showTriangle && !isSubmenu && (\n          <span\n            aria-hidden\n            style={styles.triangle}\n            className=\"bg-surface absolute border-border-subtle border-t border-l\"\n          />\n        )}\n        <VStack\n          style={styles.menu}\n          className={classNames(\n            className,\n            \"h-auto bg-surface rounded-md shadow-lg py-1.5 border\",\n            \"border-border-subtle overflow-y-auto overflow-x-hidden mx-0.5\",\n          )}\n        >\n          {filter && (\n            <HStack\n              space={2}\n              className=\"pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs\"\n            >\n              <Icon icon=\"search\" size=\"xs\" />\n              <div className=\"text\">{filter}</div>\n            </HStack>\n          )}\n          {filteredItems.length === 0 && (\n            <span className=\"text-text-subtlest text-center px-2 py-1\">No matches</span>\n          )}\n          {filteredItems.map((item, i) => {\n            if (item.hidden) {\n              return null;\n            }\n            if (item.type === \"separator\") {\n              return (\n                <Separator\n                  // oxlint-disable-next-line react/no-array-index-key -- Nothing else available\n                  key={i}\n                  className={classNames(\"my-1.5\", item.label ? \"ml-2\" : null)}\n                >\n                  {item.label}\n                </Separator>\n              );\n            }\n            if (item.type === \"content\") {\n              return (\n                // oxlint-disable-next-line jsx-a11y/no-static-element-interactions\n                // oxlint-disable-next-line react/no-array-index-key\n                <div key={i} className={classNames(\"my-1 mx-2 max-w-xs\")} onClick={onClose}>\n                  {item.label}\n                </div>\n              );\n            }\n            const isParentOfActiveSubmenu = activeSubmenu?.item === item;\n            return (\n              <MenuItem\n                focused={i === selectedIndex}\n                isParentOfActiveSubmenu={isParentOfActiveSubmenu}\n                onFocus={handleFocus}\n                onSelect={handleSelect}\n                onHover={handleItemHover}\n                // oxlint-disable-next-line react/no-array-index-key\n                key={i}\n                item={item}\n              />\n            );\n          })}\n        </VStack>\n        {activeSubmenu && (\n          // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- Container div that cancels hover timeout\n          <div\n            ref={submenuRef}\n            onMouseEnter={() => {\n              if (submenuTimeoutRef.current) {\n                clearTimeout(submenuTimeoutRef.current);\n              }\n            }}\n          >\n            <Menu\n              isSubmenu\n              isOpen\n              items={activeSubmenu.item.submenu ?? []}\n              defaultSelectedIndex={activeSubmenu.viaKeyboard ? 0 : null}\n              onClose={() => setActiveSubmenu(null)}\n              onCloseAll={handleCloseAll}\n              triggerShape={submenuTriggerShape}\n            />\n          </div>\n        )}\n      </m.div>\n    );\n\n    // Hotkeys must be rendered even when menu is closed (so they work globally)\n    const hotKeyElements = items.map(\n      (item, i) =>\n        item.type !== \"separator\" &&\n        item.type !== \"content\" &&\n        !item.hotKeyLabelOnly &&\n        item.hotKeyAction && (\n          <MenuItemHotKey\n            key={`${item.hotKeyAction}::${i}`}\n            onSelect={handleSelect}\n            item={item}\n            action={item.hotKeyAction}\n          />\n        ),\n    );\n\n    if (!isOpen) {\n      return <>{hotKeyElements}</>;\n    }\n\n    if (isSubmenu) {\n      return menuContent;\n    }\n\n    return (\n      <>\n        {hotKeyElements}\n        <Overlay noBackdrop open={isOpen} portalName=\"dropdown-menu\">\n          {menuContent}\n        </Overlay>\n      </>\n    );\n  },\n);\n\ninterface MenuItemProps {\n  className?: string;\n  item: DropdownItemDefault;\n  onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;\n  onFocus: (item: DropdownItemDefault) => void;\n  onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;\n  focused: boolean;\n  isParentOfActiveSubmenu?: boolean;\n}\n\nfunction MenuItem({\n  className,\n  focused,\n  onFocus,\n  onHover,\n  item,\n  onSelect,\n  isParentOfActiveSubmenu,\n  ...props\n}: MenuItemProps) {\n  const [isLoading, setIsLoading] = useState(false);\n  const handleClick = useCallback(async () => {\n    if (item.waitForOnSelect) setIsLoading(true);\n    await onSelect?.(item, buttonRef.current ?? undefined);\n    if (item.waitForOnSelect) setIsLoading(false);\n  }, [item, onSelect]);\n\n  const handleFocus = useCallback(\n    (e: ReactFocusEvent<HTMLButtonElement>) => {\n      e.stopPropagation(); // Don't trigger focus on any parents\n      return onFocus?.(item);\n    },\n    [item, onFocus],\n  );\n\n  const buttonRef = useRef<HTMLButtonElement | null>(null);\n  const initRef = useCallback(\n    (el: HTMLButtonElement | null) => {\n      buttonRef.current = el;\n      if (el === null) return;\n      if (focused) {\n        setTimeout(() => el.focus(), 0);\n      }\n    },\n    [focused],\n  );\n\n  const handleMouseEnter = (e: MouseEvent<HTMLButtonElement>) => {\n    onHover(item, e.currentTarget);\n    e.currentTarget.focus();\n  };\n\n  const rightSlot = item.submenu ? (\n    <Icon icon=\"chevron_right\" color=\"secondary\" />\n  ) : (\n    (item.rightSlot ?? <Hotkey variant=\"text\" action={item.hotKeyAction ?? null} />)\n  );\n\n  return (\n    <Button\n      ref={initRef}\n      size=\"sm\"\n      tabIndex={-1}\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={(e) => e.currentTarget.blur()}\n      disabled={item.disabled}\n      onFocus={handleFocus}\n      onClick={handleClick}\n      justify=\"start\"\n      leftSlot={\n        (isLoading || item.leftSlot || item.icon) && (\n          <div className={classNames(\"pr-2 flex justify-start [&_svg]:opacity-70\")}>\n            {isLoading ? <LoadingIcon /> : item.icon ? <Icon icon={item.icon} /> : item.leftSlot}\n          </div>\n        )\n      }\n      rightSlot={rightSlot && <div className=\"ml-auto pl-3\">{rightSlot}</div>}\n      innerClassName=\"!text-left\"\n      color=\"custom\"\n      className={classNames(\n        className,\n        \"h-xs\", // More compact\n        \"min-w-[8rem] outline-none px-2 mx-1.5 flex whitespace-nowrap\",\n        \"focus:bg-surface-highlight focus:text rounded focus:outline-none focus-visible:outline-1\",\n        isParentOfActiveSubmenu && \"bg-surface-highlight text rounded\",\n        item.color === \"danger\" && \"!text-danger\",\n        item.color === \"primary\" && \"!text-primary\",\n        item.color === \"success\" && \"!text-success\",\n        item.color === \"warning\" && \"!text-warning\",\n        item.color === \"notice\" && \"!text-notice\",\n        item.color === \"info\" && \"!text-info\",\n      )}\n      {...props}\n    >\n      <div className={classNames(\"truncate min-w-[5rem]\")}>{item.label}</div>\n    </Button>\n  );\n}\n\ninterface MenuItemHotKeyProps {\n  action: HotkeyAction | undefined;\n  onSelect: MenuItemProps[\"onSelect\"];\n  item: MenuItemProps[\"item\"];\n}\n\nfunction MenuItemHotKey({ action, onSelect, item }: MenuItemHotKeyProps) {\n  useHotKey(action ?? null, () => onSelect(item));\n  return null;\n}\n\nfunction sign(\n  p1: { x: number; y: number },\n  p2: { x: number; y: number },\n  p3: { x: number; y: number },\n) {\n  return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);\n}\n\nfunction isPointInTriangle(\n  pt: { x: number; y: number },\n  v1: { x: number; y: number },\n  v2: { x: number; y: number },\n  v3: { x: number; y: number },\n) {\n  const d1 = sign(pt, v1, v2);\n  const d2 = sign(pt, v2, v3);\n  const d3 = sign(pt, v3, v1);\n\n  const has_neg = d1 < 0 || d2 < 0 || d3 < 0;\n  const has_pos = d1 > 0 || d2 > 0 || d3 > 0;\n\n  return !(has_neg && has_pos);\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/BetterMatchDecorator.ts",
    "content": "import { type DecorationSet, MatchDecorator, type ViewUpdate } from \"@codemirror/view\";\n\n/**\n * This is a custom MatchDecorator that will not decorate a match if the selection is inside it\n */\nexport class BetterMatchDecorator extends MatchDecorator {\n  updateDeco(update: ViewUpdate, deco: DecorationSet): DecorationSet {\n    if (!update.startState.selection.eq(update.state.selection)) {\n      return super.createDeco(update.view);\n    }\n    return super.updateDeco(update, deco);\n  }\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/DiffViewer.css",
    "content": ".cm-wrapper.cm-multiline .cm-mergeView {\n  @apply h-full w-full overflow-auto pr-0.5;\n\n  .cm-mergeViewEditors {\n    @apply w-full min-h-full;\n  }\n\n  .cm-mergeViewEditor {\n    @apply w-full min-h-full relative;\n\n    .cm-collapsedLines {\n      @apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded cursor-default;\n    }\n  }\n\n  .cm-line {\n    @apply pl-1.5;\n  }\n  .cm-changedLine {\n    /* Round top corners only if previous line is not a changed line */\n    &:not(.cm-changedLine + &) {\n      @apply rounded-t;\n    }\n    /* Round bottom corners only if next line is not a changed line */\n    &:not(:has(+ .cm-changedLine)) {\n      @apply rounded-b;\n    }\n  }\n\n  /* Let content grow and disable individual scrolling for sync */\n  .cm-editor {\n    @apply h-auto relative !important;\n    position: relative !important;\n  }\n\n  .cm-scroller {\n    @apply overflow-visible !important;\n  }\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/DiffViewer.tsx",
    "content": "import { yaml } from \"@codemirror/lang-yaml\";\nimport { syntaxHighlighting } from \"@codemirror/language\";\nimport { MergeView } from \"@codemirror/merge\";\nimport { EditorView } from \"@codemirror/view\";\nimport classNames from \"classnames\";\nimport { useEffect, useRef } from \"react\";\nimport \"./DiffViewer.css\";\nimport { readonlyExtensions, syntaxHighlightStyle } from \"./extensions\";\n\ninterface Props {\n  /** Original/previous version (left side) */\n  original: string;\n  /** Modified/current version (right side) */\n  modified: string;\n  className?: string;\n}\n\nexport function DiffViewer({ original, modified, className }: Props) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const viewRef = useRef<MergeView | null>(null);\n\n  useEffect(() => {\n    if (!containerRef.current) return;\n\n    // Clean up previous instance\n    viewRef.current?.destroy();\n\n    const sharedExtensions = [\n      yaml(),\n      syntaxHighlighting(syntaxHighlightStyle),\n      ...readonlyExtensions,\n      EditorView.lineWrapping,\n    ];\n\n    viewRef.current = new MergeView({\n      a: {\n        doc: original,\n        extensions: sharedExtensions,\n      },\n      b: {\n        doc: modified,\n        extensions: sharedExtensions,\n      },\n      parent: containerRef.current,\n      collapseUnchanged: { margin: 2, minSize: 3 },\n      highlightChanges: false,\n      gutter: true,\n      orientation: \"a-b\",\n      revertControls: undefined,\n    });\n\n    return () => {\n      viewRef.current?.destroy();\n      viewRef.current = null;\n    };\n  }, [original, modified]);\n\n  return (\n    <div\n      ref={containerRef}\n      className={classNames(\"cm-wrapper cm-multiline h-full w-full\", className)}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/Editor.css",
    "content": ".cm-wrapper {\n  @apply h-full overflow-hidden;\n\n  .cm-editor {\n    @apply w-full block text-base;\n\n    /* Regular cursor */\n\n    .cm-cursor {\n      @apply border-text !important;\n      /* Widen the cursor a bit */\n      @apply border-l-[2px];\n    }\n\n    /* Vim-mode cursor */\n\n    .cm-fat-cursor {\n      @apply outline-0 bg-text !important;\n      @apply text-surface !important;\n    }\n\n    /* Matching bracket */\n\n    .cm-matchingBracket {\n      @apply bg-transparent border-b border-b-text-subtle;\n    }\n\n    &:not(.cm-focused) {\n      .cm-cursor,\n      .cm-fat-cursor {\n        @apply hidden;\n      }\n    }\n\n    &.cm-focused {\n      outline: none !important;\n    }\n\n    .cm-content {\n      @apply py-0;\n    }\n\n    .cm-line {\n      @apply w-full;\n      /* Important! Ensure it spans the entire width */\n      @apply w-full text-text px-0;\n\n      /* So the search highlight border is not cut off by editor view */\n      @apply pl-[1px];\n    }\n\n    .cm-placeholder {\n      @apply text-placeholder;\n    }\n\n    .cm-scroller {\n      /* Inherit line-height from outside */\n      line-height: inherit;\n\n      * {\n        @apply cursor-text;\n        @apply caret-transparent !important;\n      }\n    }\n\n    .cm-selectionBackground {\n      @apply bg-selection !important;\n    }\n\n    /* Style gutters */\n\n    .cm-gutters {\n      @apply border-0 text-text-subtlest bg-surface pr-1.5;\n      /* Not sure why, but there's a tiny gap left of the gutter that you can see text\n         through. Move left slightly to fix that. */\n      @apply -left-[1px];\n\n      .cm-gutterElement {\n        @apply cursor-default;\n      }\n    }\n\n    .cm-gutter-lint {\n      @apply w-auto !important;\n\n      .cm-gutterElement {\n        @apply px-0;\n      }\n\n      .cm-lint-marker {\n        @apply cursor-default opacity-80 hover:opacity-100 transition-opacity;\n        @apply rounded-full w-[0.9em] h-[0.9em];\n\n        content: \"\";\n\n        &.cm-lint-marker-error {\n          @apply bg-danger;\n        }\n      }\n    }\n\n    .template-tag {\n      /* Colors */\n      @apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default;\n      @apply hover:border-border hover:text-text hover:bg-surface-highlight;\n\n      @apply inline border px-1 mx-[0.5px] rounded dark:shadow;\n\n      -webkit-text-security: none;\n\n      * {\n        @apply cursor-default;\n      }\n\n      .fn {\n        @apply inline-block;\n        .fn-inner {\n          @apply text-text-subtle max-w-[40em] italic inline-flex items-end whitespace-pre text-[0.9em];\n        }\n        .fn-arg-name {\n          /* Nothing yet */\n          @apply opacity-60;\n        }\n        .fn-arg-value {\n          @apply inline-block truncate;\n        }\n        .fn-bracket {\n          @apply text-text-subtle opacity-30;\n        }\n      }\n    }\n\n    .hyperlink-widget {\n      & > * {\n        @apply underline;\n      }\n\n      &:hover > * {\n        @apply text-primary;\n      }\n\n      -webkit-text-security: none;\n    }\n  }\n\n  &.cm-singleline {\n    .cm-editor {\n      @apply w-full h-full;\n    }\n\n    .cm-scroller {\n      @apply font-mono text-xs;\n\n      /* Hide scrollbars */\n\n      &::-webkit-scrollbar-corner,\n      &::-webkit-scrollbar {\n        @apply hidden !important;\n      }\n    }\n  }\n\n  &.cm-multiline {\n    &.cm-full-height {\n      @apply relative;\n\n      .cm-editor {\n        @apply inset-0 absolute;\n        position: absolute !important;\n      }\n    }\n\n    .cm-editor {\n      @apply h-full;\n    }\n\n    .cm-scroller {\n      @apply font-mono text-editor;\n    }\n  }\n}\n\n/* Style search matches */\n.cm-searchMatch {\n  @apply bg-transparent !important;\n  @apply rounded-[2px] outline outline-1;\n\n  &.cm-searchMatch-selected {\n    @apply outline-text;\n    @apply bg-text !important;\n\n    &,\n    * {\n      @apply text-surface font-semibold !important;\n    }\n  }\n}\n\n/* Obscure text for password fields */\n.cm-wrapper.cm-obscure-text .cm-line {\n  -webkit-text-security: disc;\n}\n\n/* Obscure text for password fields */\n.cm-wrapper.cm-obscure-text .cm-line {\n  -webkit-text-security: disc;\n\n  .cm-placeholder {\n    -webkit-text-security: none;\n  }\n}\n\n.cm-editor .cm-gutterElement {\n  @apply flex items-center;\n  transition: color var(--transition-duration);\n}\n\n.cm-editor .fold-gutter-icon {\n  @apply pt-[0.25em] pl-[0.4em] px-[0.4em] h-4 rounded;\n  @apply cursor-default !important;\n}\n\n.cm-editor .fold-gutter-icon::after {\n  @apply block w-1.5 h-1.5 p-0.5 border-transparent border-l border-b border-l-[currentColor] border-b-[currentColor] content-[''];\n}\n\n/* Rotate the fold gutter chevron when open */\n.cm-editor .fold-gutter-icon[data-open]::after {\n  @apply rotate-[-45deg];\n}\n\n/* Adjust fold gutter icon position after rotation */\n.cm-editor .fold-gutter-icon:not([data-open])::after {\n  @apply relative -left-[0.1em] top-[0.1em] rotate-[-135deg];\n}\n\n.cm-editor .fold-gutter-icon:hover {\n  @apply text-text bg-surface-highlight;\n}\n\n.cm-editor .cm-foldPlaceholder {\n  @apply px-2 border border-border-subtle bg-surface-highlight;\n  @apply hover:text-text hover:border-border-subtle text-text;\n  @apply cursor-default !important;\n}\n\n.cm-editor .cm-activeLineGutter {\n  @apply bg-transparent text-text-subtle;\n}\n\n/* Cursor and mouse cursor for readonly mode */\n.cm-wrapper.cm-readonly {\n  &.cm-singleline * {\n    @apply cursor-default;\n  }\n}\n\n.cm-singleline .cm-editor {\n  .cm-content {\n    @apply h-full flex items-center;\n\n    /* Break characters on line wrapping mode, useful for URL field.\n                 * We can make this dynamic if we need it to be configurable later\n                 */\n\n    &.cm-lineWrapping {\n      @apply break-all;\n    }\n  }\n}\n\n.cm-tooltip-lint {\n  @apply font-mono text-editor rounded overflow-hidden bg-surface-highlight border border-border shadow !important;\n\n  .cm-diagnostic-error {\n    @apply border-l-danger px-4 py-2;\n  }\n}\n\n.cm-lintPoint {\n  &.cm-lintPoint-error {\n    &::after {\n      @apply border-b-danger;\n    }\n  }\n}\n\n.cm-tooltip.cm-tooltip-hover {\n  @apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto text-sm;\n  @apply p-1.5;\n\n  /* Style the tooltip for popping up \"open in browser\" and other stuff */\n\n  a,\n  button {\n    @apply text-text hover:bg-surface-highlight w-full h-sm flex items-center px-2 rounded;\n  }\n\n  a {\n    @apply cursor-default !important;\n\n    &::after {\n      @apply text-text bg-text h-3 w-3 ml-1;\n      content: \"\";\n      -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' fill='black' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z'/%3E%3Cpath fill-rule='evenodd' d='M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z'/%3E%3C/svg%3E\");\n      -webkit-mask-size: contain;\n      display: inline-block;\n    }\n  }\n}\n\n/* NOTE: Extra selector required to override default styles */\n.cm-tooltip.cm-tooltip-autocomplete,\n.cm-tooltip.cm-completionInfo {\n  @apply shadow-lg bg-surface rounded text-text-subtle border border-border-subtle z-50 pointer-events-auto;\n\n  & * {\n    @apply font-mono text-editor !important;\n  }\n\n  .cm-completionIcon {\n    @apply opacity-80 italic;\n\n    &::after {\n      content: \"a\" !important; /* Default (eg. for GraphQL) */\n    }\n\n    &.cm-completionIcon-function::after {\n      content: \"f\" !important;\n      @apply text-info;\n    }\n\n    &.cm-completionIcon-variable::after {\n      content: \"x\" !important;\n      @apply text-primary;\n    }\n\n    &.cm-completionIcon-namespace::after {\n      content: \"n\" !important;\n      @apply text-warning;\n    }\n\n    &.cm-completionIcon-constant::after {\n      content: \"c\" !important;\n      @apply text-notice;\n    }\n\n    &.cm-completionIcon-class::after {\n      content: \"o\" !important;\n    }\n\n    &.cm-completionIcon-enum::after {\n      content: \"e\" !important;\n    }\n\n    &.cm-completionIcon-interface::after {\n      content: \"i\" !important;\n    }\n\n    &.cm-completionIcon-keyword::after {\n      content: \"k\" !important;\n    }\n\n    &.cm-completionIcon-method::after {\n      content: \"m\" !important;\n    }\n\n    &.cm-completionIcon-property::after {\n      content: \"a\" !important;\n    }\n\n    &.cm-completionIcon-text::after {\n      content: \"t\" !important;\n    }\n\n    &.cm-completionIcon-type::after {\n      content: \"t\" !important;\n    }\n  }\n\n  &.cm-completionInfo {\n    @apply mx-0.5 -mt-0.5 font-sans;\n  }\n\n  * {\n    @apply transition-none;\n  }\n\n  &.cm-tooltip-autocomplete {\n    @apply font-mono;\n\n    & > ul {\n      @apply p-1 max-h-[40vh];\n    }\n\n    & > ul > li {\n      @apply cursor-default px-2 h-[2em] rounded-sm text-text flex items-center;\n    }\n\n    & > ul > li[aria-selected] {\n      @apply bg-surface-highlight text-text;\n    }\n\n    .cm-completionIcon {\n      @apply text-sm flex items-center pb-0.5 flex-shrink-0;\n    }\n\n    .cm-completionLabel {\n      @apply text-text;\n    }\n\n    .cm-completionDetail {\n      @apply ml-auto pl-6 text-text-subtle;\n    }\n  }\n}\n\n.cm-editor .cm-panels {\n  @apply bg-surface-highlight backdrop-blur-sm p-1 mb-1 text-text z-20 rounded-md;\n\n  input,\n  button {\n    @apply rounded-sm outline-none;\n  }\n\n  button {\n    @apply border-border-subtle bg-surface-highlight text-text hover:border-info;\n    @apply appearance-none bg-none cursor-default;\n  }\n\n  button[name=\"close\"] {\n    @apply text-text-subtle hocus:text-text px-2 -mr-1.5 !important;\n  }\n\n  input {\n    @apply bg-surface border-border-subtle focus:border-border-focus;\n    @apply border outline-none;\n  }\n\n  input.cm-textfield {\n    @apply cursor-text;\n  }\n\n  .cm-search label {\n    @apply inline-flex items-center h-6 px-1.5 rounded-sm border border-border-subtle cursor-default text-text-subtle text-xs;\n\n    input[type=\"checkbox\"] {\n      @apply hidden;\n    }\n\n    &:has(:checked) {\n      @apply text-primary border-border;\n    }\n  }\n\n  /* Hide the \"All\" button */\n\n  button[name=\"select\"] {\n    @apply hidden;\n  }\n\n  /* Replace next/prev button text with chevron icons */\n\n  .cm-search button[name=\"next\"],\n  .cm-search button[name=\"prev\"] {\n    @apply text-[0px] w-7 h-6 inline-flex items-center justify-center border border-border-subtle mr-1;\n  }\n\n  .cm-search button[name=\"prev\"]::after,\n  .cm-search button[name=\"next\"]::after {\n    @apply block w-3.5 h-3.5 bg-text;\n    content: \"\";\n  }\n\n  .cm-search button[name=\"prev\"]::after {\n    -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E\");\n    mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E\");\n  }\n\n  .cm-search button[name=\"next\"]::after {\n    -webkit-mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E\");\n    mask-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E\");\n  }\n\n  .cm-search-match-count {\n    @apply text-text-subtle text-xs font-mono whitespace-nowrap px-1.5 py-0.5 self-center;\n  }\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/Editor.tsx",
    "content": "import { startCompletion } from \"@codemirror/autocomplete\";\nimport { defaultKeymap, historyField, indentWithTab } from \"@codemirror/commands\";\nimport { foldState, forceParsing } from \"@codemirror/language\";\nimport type { EditorStateConfig, Extension } from \"@codemirror/state\";\nimport { Compartment, EditorState } from \"@codemirror/state\";\nimport { EditorView, keymap, placeholder as placeholderExt, tooltips } from \"@codemirror/view\";\nimport { emacs } from \"@replit/codemirror-emacs\";\nimport { vim } from \"@replit/codemirror-vim\";\n\nimport { vscodeKeymap } from \"@replit/codemirror-vscode-keymap\";\nimport type { EditorKeymap } from \"@yaakapp-internal/models\";\nimport { settingsAtom } from \"@yaakapp-internal/models\";\nimport type { EditorLanguage, TemplateFunction } from \"@yaakapp-internal/plugins\";\nimport classNames from \"classnames\";\nimport type { GraphQLSchema } from \"graphql\";\nimport { useAtomValue } from \"jotai\";\nimport { md5 } from \"js-md5\";\nimport type { ReactNode, RefObject } from \"react\";\nimport {\n  Children,\n  cloneElement,\n  isValidElement,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n} from \"react\";\nimport { activeEnvironmentAtom } from \"../../../hooks/useActiveEnvironment\";\nimport type { WrappedEnvironmentVariable } from \"../../../hooks/useEnvironmentVariables\";\nimport { useEnvironmentVariables } from \"../../../hooks/useEnvironmentVariables\";\nimport { eventMatchesHotkey } from \"../../../hooks/useHotKey\";\nimport { useRequestEditor } from \"../../../hooks/useRequestEditor\";\nimport { useTemplateFunctionCompletionOptions } from \"../../../hooks/useTemplateFunctions\";\nimport { editEnvironment } from \"../../../lib/editEnvironment\";\nimport { tryFormatJson, tryFormatXml } from \"../../../lib/formatters\";\nimport { jotaiStore } from \"../../../lib/jotai\";\nimport { withEncryptionEnabled } from \"../../../lib/setupOrConfigureEncryption\";\nimport { TemplateFunctionDialog } from \"../../TemplateFunctionDialog\";\nimport { IconButton } from \"../IconButton\";\nimport { HStack } from \"../Stacks\";\nimport \"./Editor.css\";\nimport {\n  baseExtensions,\n  getLanguageExtension,\n  multiLineExtensions,\n  readonlyExtensions,\n} from \"./extensions\";\nimport type { GenericCompletionConfig } from \"./genericCompletion\";\nimport { singleLineExtensions } from \"./singleLine\";\n\n// VSCode's Tab actions mess with the single-line editor tab actions, so remove it.\nconst vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== \"Tab\");\n\nconst keymapExtensions: Record<EditorKeymap, Extension> = {\n  vim: vim(),\n  emacs: emacs(),\n  vscode: keymap.of(vsCodeWithoutTab),\n  default: [],\n};\n\nexport interface EditorProps {\n  actions?: ReactNode;\n  autoFocus?: boolean;\n  autoSelect?: boolean;\n  autocomplete?: GenericCompletionConfig;\n  autocompleteFunctions?: boolean;\n  autocompleteVariables?: boolean | ((v: WrappedEnvironmentVariable) => boolean);\n  className?: string;\n  defaultValue?: string | null;\n  disableTabIndent?: boolean;\n  disabled?: boolean;\n  extraExtensions?: Extension[] | Extension;\n  forcedEnvironmentId?: string;\n  forceUpdateKey?: string | number;\n  format?: (v: string) => Promise<string>;\n  heightMode?: \"auto\" | \"full\";\n  hideGutter?: boolean;\n  id?: string;\n  language?: EditorLanguage | \"pairs\" | \"url\" | \"timeline\" | null;\n  lintExtension?: Extension;\n  graphQLSchema?: GraphQLSchema | null;\n  onBlur?: () => void;\n  onChange?: (value: string) => void;\n  onFocus?: () => void;\n  onKeyDown?: (e: KeyboardEvent) => void;\n  onPaste?: (value: string) => void;\n  onPasteOverwrite?: (e: ClipboardEvent, value: string) => void;\n  placeholder?: string;\n  readOnly?: boolean;\n  singleLine?: boolean;\n  containerOnly?: boolean;\n  stateKey: string | null;\n  tooltipContainer?: HTMLElement;\n  type?: \"text\" | \"password\";\n  wrapLines?: boolean;\n  setRef?: (view: EditorView | null) => void;\n}\n\nconst stateFields = { history: historyField, folds: foldState };\n\nconst emptyVariables: WrappedEnvironmentVariable[] = [];\nconst emptyExtension: Extension = [];\n\nexport function Editor(props: EditorProps) {\n  return <EditorInner key={props.stateKey} {...props} />;\n}\n\nfunction EditorInner({\n  actions,\n  autoFocus,\n  autoSelect,\n  autocomplete,\n  autocompleteFunctions,\n  autocompleteVariables,\n  className,\n  defaultValue,\n  disableTabIndent,\n  disabled,\n  extraExtensions,\n  forcedEnvironmentId,\n  forceUpdateKey,\n  format,\n  heightMode,\n  hideGutter,\n  graphQLSchema,\n  language,\n  lintExtension,\n  onBlur,\n  onChange,\n  onFocus,\n  onKeyDown,\n  onPaste,\n  onPasteOverwrite,\n  placeholder,\n  readOnly,\n  singleLine,\n  containerOnly,\n  stateKey,\n  type,\n  wrapLines,\n  setRef,\n}: EditorProps) {\n  const settings = useAtomValue(settingsAtom);\n\n  const allEnvironmentVariables = useEnvironmentVariables(forcedEnvironmentId ?? null);\n  const useTemplating = !!(autocompleteFunctions || autocompleteVariables || autocomplete);\n  const environmentVariables = useMemo(() => {\n    if (!autocompleteVariables) return emptyVariables;\n    return typeof autocompleteVariables === \"function\"\n      ? allEnvironmentVariables.filter(autocompleteVariables)\n      : allEnvironmentVariables;\n  }, [allEnvironmentVariables, autocompleteVariables]);\n\n  if (settings && wrapLines === undefined) {\n    wrapLines = settings.editorSoftWrap;\n  }\n\n  if (disabled) {\n    readOnly = true;\n  }\n\n  if (\n    singleLine ||\n    language == null ||\n    language === \"text\" ||\n    language === \"url\" ||\n    language === \"pairs\"\n  ) {\n    disableTabIndent = true;\n  }\n\n  if (format == null && !readOnly) {\n    format =\n      language === \"json\"\n        ? tryFormatJson\n        : language === \"xml\" || language === \"html\"\n          ? tryFormatXml\n          : undefined;\n  }\n\n  const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);\n\n  // Use ref so we can update the handler without re-initializing the editor\n  const handleChange = useRef<EditorProps[\"onChange\"]>(onChange);\n  useEffect(() => {\n    handleChange.current = onChange;\n  }, [onChange]);\n\n  // Use ref so we can update the handler without re-initializing the editor\n  const handlePaste = useRef<EditorProps[\"onPaste\"]>(onPaste);\n  useEffect(() => {\n    handlePaste.current = onPaste;\n  }, [onPaste]);\n\n  // Use ref so we can update the handler without re-initializing the editor\n  const handlePasteOverwrite = useRef<EditorProps[\"onPasteOverwrite\"]>(onPasteOverwrite);\n  useEffect(() => {\n    handlePasteOverwrite.current = onPasteOverwrite;\n  }, [onPasteOverwrite]);\n\n  // Use ref so we can update the handler without re-initializing the editor\n  const handleFocus = useRef<EditorProps[\"onFocus\"]>(onFocus);\n  useEffect(() => {\n    handleFocus.current = onFocus;\n  }, [onFocus]);\n\n  // Use ref so we can update the handler without re-initializing the editor\n  const handleBlur = useRef<EditorProps[\"onBlur\"]>(onBlur);\n  useEffect(() => {\n    handleBlur.current = onBlur;\n  }, [onBlur]);\n\n  // Use ref so we can update the handler without re-initializing the editor\n  const handleKeyDown = useRef<EditorProps[\"onKeyDown\"]>(onKeyDown);\n  useEffect(() => {\n    handleKeyDown.current = onKeyDown;\n  }, [onKeyDown]);\n\n  // Update placeholder\n  const placeholderCompartment = useRef(new Compartment());\n  useEffect(\n    function configurePlaceholder() {\n      if (cm.current === null) return;\n      const ext = placeholderExt(placeholderElFromText(placeholder));\n      const effects = placeholderCompartment.current.reconfigure(ext);\n      cm.current?.view.dispatch({ effects });\n    },\n    [placeholder],\n  );\n\n  // Update vim\n  const keymapCompartment = useRef(new Compartment());\n  useEffect(\n    function configureKeymap() {\n      if (cm.current === null) return;\n      const current = keymapCompartment.current.get(cm.current.view.state) ?? [];\n      // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary\n      if (settings.editorKeymap === \"default\" && current === keymapExtensions.default) return; // Nothing to do\n      if (settings.editorKeymap === \"vim\" && current === keymapExtensions.vim) return; // Nothing to do\n      if (settings.editorKeymap === \"vscode\" && current === keymapExtensions.vscode) return; // Nothing to do\n      if (settings.editorKeymap === \"emacs\" && current === keymapExtensions.emacs) return; // Nothing to do\n\n      const ext = keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default;\n      const effects = keymapCompartment.current.reconfigure(ext);\n      cm.current.view.dispatch({ effects });\n    },\n    [settings.editorKeymap],\n  );\n\n  // Update wrap lines\n  const wrapLinesCompartment = useRef(new Compartment());\n  useEffect(\n    function configureWrapLines() {\n      if (cm.current === null) return;\n      const current = wrapLinesCompartment.current.get(cm.current.view.state) ?? emptyExtension;\n      // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary\n      if (wrapLines && current !== emptyExtension) return; // Nothing to do\n      if (!wrapLines && current === emptyExtension) return; // Nothing to do\n\n      const ext = wrapLines ? EditorView.lineWrapping : emptyExtension;\n      const effects = wrapLinesCompartment.current.reconfigure(ext);\n      cm.current?.view.dispatch({ effects });\n    },\n    [wrapLines],\n  );\n\n  // Update tab indent\n  const tabIndentCompartment = useRef(new Compartment());\n  useEffect(\n    function configureTabIndent() {\n      if (cm.current === null) return;\n      const current = tabIndentCompartment.current.get(cm.current.view.state) ?? emptyExtension;\n      // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary\n      if (disableTabIndent && current !== emptyExtension) return; // Nothing to do\n      if (!disableTabIndent && current === emptyExtension) return; // Nothing to do\n\n      const ext = !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension;\n      const effects = tabIndentCompartment.current.reconfigure(ext);\n      cm.current?.view.dispatch({ effects });\n    },\n    [disableTabIndent],\n  );\n\n  const onClickFunction = useCallback(\n    async (fn: TemplateFunction, tagValue: string, startPos: number) => {\n      const show = () => {\n        if (cm.current === null) return;\n        TemplateFunctionDialog.show(fn, tagValue, startPos, cm.current.view);\n      };\n\n      if (fn.name === \"secure\") {\n        withEncryptionEnabled(show);\n      } else {\n        show();\n      }\n    },\n    [],\n  );\n\n  const onClickVariable = useCallback(\n    // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    async (v: WrappedEnvironmentVariable, _tagValue: string, _startPos: number) => {\n      await editEnvironment(v.environment, { addOrFocusVariable: v.variable });\n    },\n    [],\n  );\n\n  const onClickMissingVariable = useCallback(async (name: string) => {\n    const activeEnvironment = jotaiStore.get(activeEnvironmentAtom);\n    await editEnvironment(activeEnvironment, {\n      addOrFocusVariable: { name, value: \"\", enabled: true },\n    });\n  }, []);\n\n  const [, { focusParamValue }] = useRequestEditor();\n  const onClickPathParameter = useCallback(\n    async (name: string) => {\n      focusParamValue(name);\n    },\n    [focusParamValue],\n  );\n\n  const completionOptions = useTemplateFunctionCompletionOptions(\n    onClickFunction,\n    !!autocompleteFunctions,\n  );\n\n  // Update the language extension when the language changes\n  // oxlint-disable-next-line react-hooks/exhaustive-deps -- intentionally limited deps\n  useEffect(() => {\n    if (cm.current === null) return;\n    const { view, languageCompartment } = cm.current;\n    const ext = getLanguageExtension({\n      useTemplating,\n      language,\n      lintExtension,\n      hideGutter,\n      environmentVariables,\n      autocomplete,\n      completionOptions,\n      onClickVariable,\n      onClickMissingVariable,\n      onClickPathParameter,\n      graphQLSchema: graphQLSchema ?? null,\n    });\n    view.dispatch({ effects: languageCompartment.reconfigure(ext) });\n  }, [\n    language,\n    lintExtension,\n    autocomplete,\n    environmentVariables,\n    onClickFunction,\n    onClickVariable,\n    onClickMissingVariable,\n    onClickPathParameter,\n    completionOptions,\n    useTemplating,\n    graphQLSchema,\n    hideGutter,\n  ]);\n\n  // Initialize the editor when ref mounts\n  // oxlint-disable-next-line react-hooks/exhaustive-deps -- only reinitialize when necessary\n  const initEditorRef = useCallback(\n    function initEditorRef(container: HTMLDivElement | null) {\n      if (container === null) {\n        cm.current?.view.destroy();\n        cm.current = null;\n        return;\n      }\n\n      try {\n        const languageCompartment = new Compartment();\n        const langExt = getLanguageExtension({\n          useTemplating,\n          language,\n          lintExtension,\n          completionOptions,\n          autocomplete,\n          environmentVariables,\n          onClickVariable,\n          onClickMissingVariable,\n          onClickPathParameter,\n          graphQLSchema: graphQLSchema ?? null,\n        });\n        const extensions = [\n          languageCompartment.of(langExt),\n          placeholderCompartment.current.of(placeholderExt(placeholderElFromText(placeholder))),\n          wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension),\n          tabIndentCompartment.current.of(\n            !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension,\n          ),\n          keymapCompartment.current.of(\n            keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,\n          ),\n          ...getExtensions({\n            container,\n            readOnly,\n            singleLine,\n            hideGutter,\n            stateKey,\n            onChange: handleChange,\n            onPaste: handlePaste,\n            onPasteOverwrite: handlePasteOverwrite,\n            onFocus: handleFocus,\n            onBlur: handleBlur,\n            onKeyDown: handleKeyDown,\n          }),\n          ...(Array.isArray(extraExtensions)\n            ? extraExtensions\n            : extraExtensions\n              ? [extraExtensions]\n              : []),\n        ];\n\n        const cachedJsonState = getCachedEditorState(defaultValue ?? \"\", stateKey);\n\n        const doc = `${defaultValue ?? \"\"}`;\n        const config: EditorStateConfig = { extensions, doc };\n\n        const state = cachedJsonState\n          ? EditorState.fromJSON(cachedJsonState, config, stateFields)\n          : EditorState.create(config);\n\n        const view = new EditorView({ state, parent: container });\n\n        // For large documents, the parser may parse the max number of lines and fail to add\n        // things like fold markers because of it.\n        // This forces it to parse more but keeps the timeout to the default of 100 ms.\n        forceParsing(view, 9e6, 100);\n\n        cm.current = { view, languageCompartment };\n        if (autoFocus) {\n          view.focus();\n        }\n        if (autoSelect) {\n          view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });\n        }\n        setRef?.(view);\n      } catch (e) {\n        console.log(\"Failed to initialize Codemirror\", e);\n      }\n    },\n    [forceUpdateKey],\n  );\n\n  // For read-only mode, update content when `defaultValue` changes\n  useEffect(\n    function updateReadOnlyEditor() {\n      if (readOnly && cm.current?.view != null) {\n        updateContents(cm.current.view, defaultValue || \"\");\n      }\n    },\n    [defaultValue, readOnly],\n  );\n\n  // Force input to update when receiving change and not in focus\n  useLayoutEffect(\n    function updateNonFocusedEditor() {\n      const notFocused = !cm.current?.view.hasFocus;\n      if (notFocused && cm.current != null) {\n        updateContents(cm.current.view, defaultValue || \"\");\n      }\n    },\n    [defaultValue],\n  );\n\n  // Add bg classes to actions, so they appear over the text\n  const decoratedActions = useMemo(() => {\n    const results = [];\n    const actionClassName = classNames(\n      \"bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:!opacity-100 shadow\",\n    );\n\n    if (format) {\n      results.push(\n        <IconButton\n          showConfirm\n          key=\"format\"\n          size=\"sm\"\n          title=\"Reformat contents\"\n          icon=\"magic_wand\"\n          variant=\"border\"\n          className={classNames(actionClassName)}\n          onClick={async () => {\n            if (cm.current === null) return;\n            const { doc } = cm.current.view.state;\n            const formatted = await format(doc.toString());\n            // Update editor and blur because the cursor will reset anyway\n            cm.current.view.dispatch({\n              changes: { from: 0, to: doc.length, insert: formatted },\n            });\n            cm.current.view.contentDOM.blur();\n            // Fire change event\n            onChange?.(formatted);\n          }}\n        />,\n      );\n    }\n    results.push(\n      Children.map(actions, (existingChild) => {\n        if (!isValidElement<{ className?: string }>(existingChild)) return null;\n        const existingProps = existingChild.props;\n\n        return cloneElement(existingChild, {\n          ...existingProps,\n          className: classNames(existingProps.className, actionClassName),\n        });\n      }),\n    );\n    return results;\n  }, [actions, format, onChange]);\n\n  const cmContainer = (\n    <div\n      ref={initEditorRef}\n      className={classNames(\n        className,\n        \"cm-wrapper text-base\",\n        disabled && \"opacity-disabled\",\n        type === \"password\" && \"cm-obscure-text\",\n        heightMode === \"auto\" ? \"cm-auto-height\" : \"cm-full-height\",\n        singleLine ? \"cm-singleline\" : \"cm-multiline\",\n        readOnly && \"cm-readonly\",\n      )}\n    />\n  );\n\n  if (singleLine || containerOnly) {\n    return cmContainer;\n  }\n\n  return (\n    <div className=\"group relative h-full w-full x-theme-editor bg-surface\">\n      {cmContainer}\n      {decoratedActions && (\n        <HStack\n          space={1}\n          justifyContent=\"end\"\n          className={classNames(\n            \"absolute bottom-2 left-0 right-0\",\n            \"pointer-events-none\", // No pointer events, so we don't block the editor\n          )}\n        >\n          {decoratedActions}\n        </HStack>\n      )}\n    </div>\n  );\n}\n\nfunction getExtensions({\n  stateKey,\n  container,\n  readOnly,\n  singleLine,\n  hideGutter,\n  onChange,\n  onPaste,\n  onPasteOverwrite,\n  onFocus,\n  onBlur,\n  onKeyDown,\n}: Pick<EditorProps, \"singleLine\" | \"readOnly\" | \"hideGutter\"> & {\n  stateKey: EditorProps[\"stateKey\"];\n  container: HTMLDivElement | null;\n  onChange: RefObject<EditorProps[\"onChange\"]>;\n  onPaste: RefObject<EditorProps[\"onPaste\"]>;\n  onPasteOverwrite: RefObject<EditorProps[\"onPasteOverwrite\"]>;\n  onFocus: RefObject<EditorProps[\"onFocus\"]>;\n  onBlur: RefObject<EditorProps[\"onBlur\"]>;\n  onKeyDown: RefObject<EditorProps[\"onKeyDown\"]>;\n}) {\n  // TODO: Ensure tooltips render inside the dialog if we are in one.\n  const parent =\n    container?.closest<HTMLDivElement>('[role=\"dialog\"]') ??\n    document.querySelector<HTMLDivElement>(\"#cm-portal\") ??\n    undefined;\n\n  return [\n    ...baseExtensions, // Must be first\n    EditorView.domEventHandlers({\n      focus: () => {\n        onFocus.current?.();\n      },\n      blur: () => {\n        onBlur.current?.();\n      },\n      keydown: (e, view) => {\n        // Check if the hotkey matches the editor.autocomplete action\n        if (eventMatchesHotkey(e, \"editor.autocomplete\")) {\n          e.preventDefault();\n          startCompletion(view);\n          return true;\n        }\n        onKeyDown.current?.(e);\n      },\n      paste: (e, v) => {\n        const textData = e.clipboardData?.getData(\"text/plain\") ?? \"\";\n        onPaste.current?.(textData);\n        if (v.state.selection.main.from === 0 && v.state.selection.main.to === v.state.doc.length) {\n          onPasteOverwrite.current?.(e, textData);\n        }\n      },\n    }),\n    tooltips({ parent }),\n    keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== \"Enter\") : defaultKeymap),\n    ...(singleLine ? [singleLineExtensions()] : []),\n    ...(!singleLine ? multiLineExtensions({ hideGutter }) : []),\n    ...(readOnly ? readonlyExtensions : []),\n\n    // ------------------------ //\n    // Things that must be last //\n    // ------------------------ //\n\n    EditorView.updateListener.of((update) => {\n      if (update.startState === update.state) return;\n\n      if (onChange && update.docChanged) {\n        onChange.current?.(update.state.doc.toString());\n      }\n\n      saveCachedEditorState(stateKey, update.state);\n    }),\n  ];\n}\n\nconst placeholderElFromText = (text: string | undefined) => {\n  const el = document.createElement(\"div\");\n  // Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something\n  // to do with how Yaak \"hacks\" it with CSS for single line input.\n  el.innerHTML = text ? text.replaceAll(\"\\n\", \"<br/>\") : \" \";\n  return el;\n};\n\nfunction saveCachedEditorState(stateKey: string | null, state: EditorState | null) {\n  if (!stateKey || state == null) return;\n  const stateObj = state.toJSON(stateFields);\n\n  // Save state in sessionStorage by removing doc and saving the hash of it instead.\n  // This will be checked on restore and put back in if it matches.\n  stateObj.docHash = md5(stateObj.doc);\n  stateObj.doc = undefined;\n\n  try {\n    sessionStorage.setItem(computeFullStateKey(stateKey), JSON.stringify(stateObj));\n  } catch (err) {\n    console.log(\"Failed to save to editor state\", stateKey, err);\n  }\n}\n\nfunction getCachedEditorState(doc: string, stateKey: string | null) {\n  if (stateKey == null) return;\n\n  try {\n    const stateStr = sessionStorage.getItem(computeFullStateKey(stateKey));\n    if (stateStr == null) return null;\n\n    const { docHash, ...state } = JSON.parse(stateStr);\n\n    // Ensure the doc matches the one that was used to save the state\n    if (docHash !== md5(doc)) {\n      return null;\n    }\n\n    state.doc = doc;\n    return state;\n  } catch (err) {\n    console.log(\"Failed to restore editor storage\", stateKey, err);\n  }\n\n  return null;\n}\n\nfunction computeFullStateKey(stateKey: string): string {\n  return `editor.${stateKey}`;\n}\n\nfunction updateContents(view: EditorView, text: string) {\n  // Replace codemirror contents\n  const currentDoc = view.state.doc.toString();\n\n  if (currentDoc === text) {\n    return;\n  }\n\n  if (text.startsWith(currentDoc)) {\n    // If we're just appending, append only the changes. This preserves\n    // things like scroll position.\n    view.dispatch({\n      changes: view.state.changes({\n        from: currentDoc.length,\n        insert: text.slice(currentDoc.length),\n      }),\n    });\n  } else {\n    // If we're replacing everything, reset the entire content\n    view.dispatch({\n      changes: view.state.changes({\n        from: 0,\n        to: currentDoc.length,\n        insert: text,\n      }),\n    });\n  }\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/LazyEditor.tsx",
    "content": "import { lazy, Suspense } from \"react\";\nimport type { EditorProps } from \"./Editor\";\n\nconst Editor_ = lazy(() => import(\"./Editor\").then((m) => ({ default: m.Editor })));\n\nexport function Editor(props: EditorProps) {\n  return (\n    <Suspense>\n      <Editor_ {...props} />\n    </Suspense>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/extensions.ts",
    "content": "import {\n  autocompletion,\n  closeBrackets,\n  closeBracketsKeymap,\n  completionKeymap,\n} from \"@codemirror/autocomplete\";\nimport { history, historyKeymap } from \"@codemirror/commands\";\nimport { go } from \"@codemirror/lang-go\";\nimport { java } from \"@codemirror/lang-java\";\nimport { javascript } from \"@codemirror/lang-javascript\";\nimport { markdown } from \"@codemirror/lang-markdown\";\nimport { php } from \"@codemirror/lang-php\";\nimport { python } from \"@codemirror/lang-python\";\nimport { xml } from \"@codemirror/lang-xml\";\nimport {\n  bracketMatching,\n  codeFolding,\n  foldGutter,\n  foldKeymap,\n  HighlightStyle,\n  indentOnInput,\n  LanguageSupport,\n  StreamLanguage,\n  syntaxHighlighting,\n} from \"@codemirror/language\";\nimport { c, csharp, kotlin, objectiveC } from \"@codemirror/legacy-modes/mode/clike\";\nimport { clojure } from \"@codemirror/legacy-modes/mode/clojure\";\nimport { http } from \"@codemirror/legacy-modes/mode/http\";\nimport { oCaml } from \"@codemirror/legacy-modes/mode/mllike\";\nimport { powerShell } from \"@codemirror/legacy-modes/mode/powershell\";\nimport { r } from \"@codemirror/legacy-modes/mode/r\";\nimport { ruby } from \"@codemirror/legacy-modes/mode/ruby\";\nimport { shell } from \"@codemirror/legacy-modes/mode/shell\";\nimport { swift } from \"@codemirror/legacy-modes/mode/swift\";\nimport { linter, lintGutter, lintKeymap } from \"@codemirror/lint\";\nimport { search, searchKeymap } from \"@codemirror/search\";\nimport type { Extension } from \"@codemirror/state\";\nimport { EditorState } from \"@codemirror/state\";\nimport {\n  crosshairCursor,\n  drawSelection,\n  dropCursor,\n  EditorView,\n  highlightActiveLineGutter,\n  highlightSpecialChars,\n  keymap,\n  lineNumbers,\n  rectangularSelection,\n} from \"@codemirror/view\";\nimport { tags as t } from \"@lezer/highlight\";\nimport { jsonc, jsoncLanguage } from \"@shopify/lang-jsonc\";\nimport { graphql } from \"cm6-graphql\";\nimport type { GraphQLSchema } from \"graphql\";\nimport { activeRequestIdAtom } from \"../../../hooks/useActiveRequestId\";\nimport type { WrappedEnvironmentVariable } from \"../../../hooks/useEnvironmentVariables\";\nimport { jotaiStore } from \"../../../lib/jotai\";\nimport { renderMarkdown } from \"../../../lib/markdown\";\nimport { pluralizeCount } from \"../../../lib/pluralize\";\nimport { showGraphQLDocExplorerAtom } from \"../../graphql/graphqlAtoms\";\nimport type { EditorProps } from \"./Editor\";\nimport { jsonParseLinter } from \"./json-lint\";\nimport { pairs } from \"./pairs/extension\";\nimport { searchMatchCount } from \"./searchMatchCount\";\nimport { text } from \"./text/extension\";\nimport { timeline } from \"./timeline/extension\";\nimport type { TwigCompletionOption } from \"./twig/completion\";\nimport { twig } from \"./twig/extension\";\nimport { pathParametersPlugin } from \"./twig/pathParameters\";\nimport { url } from \"./url/extension\";\n\nexport const syntaxHighlightStyle = HighlightStyle.define([\n  {\n    tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],\n    color: \"var(--textSubtlest)\",\n  },\n  {\n    tag: [t.emphasis],\n    textDecoration: \"underline\",\n  },\n  {\n    tag: [t.angleBracket, t.paren, t.bracket, t.squareBracket, t.brace, t.separator, t.punctuation],\n    color: \"var(--textSubtle)\",\n  },\n  {\n    tag: [t.link, t.name, t.tagName, t.angleBracket, t.docString, t.number],\n    color: \"var(--info)\",\n  },\n  { tag: [t.variableName], color: \"var(--success)\" },\n  { tag: [t.bool], color: \"var(--warning)\" },\n  { tag: [t.attributeName, t.propertyName], color: \"var(--primary)\" },\n  { tag: [t.attributeValue], color: \"var(--warning)\" },\n  { tag: [t.string], color: \"var(--notice)\" },\n  { tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: \"var(--danger)\" },\n]);\n\nconst syntaxTheme = EditorView.theme({}, { dark: true });\n\nconst closeBracketsExtensions: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])];\n\nconst legacyLang = (mode: Parameters<typeof StreamLanguage.define>[0]) => {\n  return () => new LanguageSupport(StreamLanguage.define(mode));\n};\n\nconst syntaxExtensions: Record<\n  NonNullable<EditorProps[\"language\"]>,\n  null | (() => LanguageSupport)\n> = {\n  graphql: null,\n  json: jsonc,\n  javascript: javascript,\n  // HTML as XML because HTML is oddly slow\n  html: xml,\n  xml: xml,\n  url: url,\n  pairs: pairs,\n  text: text,\n  timeline: timeline,\n  markdown: markdown,\n  c: legacyLang(c),\n  clojure: legacyLang(clojure),\n  csharp: legacyLang(csharp),\n  go: go,\n  http: legacyLang(http),\n  java: java,\n  kotlin: legacyLang(kotlin),\n  objective_c: legacyLang(objectiveC),\n  ocaml: legacyLang(oCaml),\n  php: php,\n  powershell: legacyLang(powerShell),\n  python: python,\n  r: legacyLang(r),\n  ruby: legacyLang(ruby),\n  shell: legacyLang(shell),\n  swift: legacyLang(swift),\n};\n\nconst closeBracketsFor: (keyof typeof syntaxExtensions)[] = [\"json\", \"javascript\", \"graphql\"];\n\nexport function getLanguageExtension({\n  useTemplating,\n  language = \"text\",\n  lintExtension,\n  environmentVariables,\n  autocomplete,\n  hideGutter,\n  onClickVariable,\n  onClickMissingVariable,\n  onClickPathParameter,\n  completionOptions,\n  graphQLSchema,\n}: {\n  useTemplating: boolean;\n  environmentVariables: WrappedEnvironmentVariable[];\n  onClickVariable: (option: WrappedEnvironmentVariable, tagValue: string, startPos: number) => void;\n  onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;\n  onClickPathParameter: (name: string) => void;\n  completionOptions: TwigCompletionOption[];\n  graphQLSchema: GraphQLSchema | null;\n} & Pick<EditorProps, \"language\" | \"autocomplete\" | \"hideGutter\" | \"lintExtension\">) {\n  const extraExtensions: Extension[] = [];\n\n  if (language === \"url\") {\n    extraExtensions.push(pathParametersPlugin(onClickPathParameter));\n  }\n\n  // Only close brackets on languages that need it\n  if (language && closeBracketsFor.includes(language)) {\n    extraExtensions.push(closeBracketsExtensions);\n  }\n\n  // GraphQL is a special exception\n  if (language === \"graphql\") {\n    return [\n      graphql(graphQLSchema ?? undefined, {\n        async onCompletionInfoRender(gqlCompletionItem): Promise<Node | null> {\n          if (!gqlCompletionItem.documentation) return null;\n          const innerHTML = await renderMarkdown(gqlCompletionItem.documentation);\n          const span = document.createElement(\"span\");\n          span.innerHTML = innerHTML;\n          return span;\n        },\n        onShowInDocs(field, type, parentType) {\n          const activeRequestId = jotaiStore.get(activeRequestIdAtom);\n          if (activeRequestId == null) return;\n          jotaiStore.set(showGraphQLDocExplorerAtom, (v) => ({\n            ...v,\n            [activeRequestId]: { field, type, parentType },\n          }));\n        },\n      }),\n      extraExtensions,\n    ];\n  }\n\n  if (language === \"json\") {\n    extraExtensions.push(lintExtension ?? linter(jsonParseLinter()));\n    extraExtensions.push(\n      jsoncLanguage.data.of({\n        commentTokens: { line: \"//\", block: { open: \"/*\", close: \"*/\" } },\n      }),\n    );\n    if (!hideGutter) {\n      extraExtensions.push(lintGutter());\n    }\n  }\n\n  const maybeBase = language ? syntaxExtensions[language] : null;\n  const base = typeof maybeBase === \"function\" ? maybeBase() : null;\n  if (base == null) {\n    return [];\n  }\n\n  if (!useTemplating) {\n    return [base, extraExtensions];\n  }\n\n  return twig({\n    base,\n    environmentVariables,\n    completionOptions,\n    autocomplete,\n    onClickVariable,\n    onClickMissingVariable,\n    onClickPathParameter,\n    extraExtensions,\n  });\n}\n\n// Filter out autocomplete start triggers from completionKeymap since we handle it via configurable hotkeys.\n// Keep navigation keys (ArrowUp/Down, Enter, Escape, etc.) but remove startCompletion bindings.\nconst filteredCompletionKeymap = completionKeymap.filter((binding) => {\n  const key = binding.key?.toLowerCase() ?? \"\";\n  const mac = (binding as { mac?: string }).mac?.toLowerCase() ?? \"\";\n  // Filter out Ctrl-Space and Mac-specific autocomplete triggers (Alt-`, Alt-i)\n  const isStartTrigger = key.includes(\"space\") || mac.includes(\"alt-\") || mac.includes(\"`\");\n  return !isStartTrigger;\n});\n\nexport const baseExtensions = [\n  highlightSpecialChars(),\n  history(),\n  dropCursor(),\n  drawSelection(),\n  autocompletion({\n    tooltipClass: () => \"x-theme-menu\",\n    closeOnBlur: true, // Set to `false` for debugging in devtools without closing it\n    defaultKeymap: false, // We handle the trigger via configurable hotkeys\n    compareCompletions: (a, b) => {\n      // Don't sort completions at all, only on boost\n      return (a.boost ?? 0) - (b.boost ?? 0);\n    },\n  }),\n  syntaxHighlighting(syntaxHighlightStyle),\n  syntaxTheme,\n  keymap.of([...historyKeymap, ...filteredCompletionKeymap]),\n];\n\nexport const readonlyExtensions = [\n  EditorState.readOnly.of(true),\n  EditorView.contentAttributes.of({ tabindex: \"-1\" }),\n];\n\nexport const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [\n  search({ top: true }),\n  searchMatchCount(),\n  hideGutter\n    ? []\n    : [\n        lineNumbers(),\n        foldGutter({\n          markerDOM: (open) => {\n            const el = document.createElement(\"div\");\n            el.classList.add(\"fold-gutter-icon\");\n            el.tabIndex = -1;\n            if (open) {\n              el.setAttribute(\"data-open\", \"\");\n            }\n            return el;\n          },\n        }),\n      ],\n  codeFolding({\n    placeholderDOM(_view, onclick, prepared) {\n      const el = document.createElement(\"span\");\n      el.onclick = onclick;\n      el.className = \"cm-foldPlaceholder\";\n      el.innerText = prepared || \"…\";\n      el.title = \"unfold\";\n      el.ariaLabel = \"folded code\";\n      return el;\n    },\n    /**\n     * Show the number of items when code folded. NOTE: this doesn't get called when restoring\n     * a previous serialized editor state, which is a bummer\n     */\n    preparePlaceholder(state, range) {\n      let count: number | undefined;\n      let startToken = \"{\";\n      let endToken = \"}\";\n\n      const prevLine = state.doc.lineAt(range.from).text;\n      const isArray = prevLine.lastIndexOf(\"[\") > prevLine.lastIndexOf(\"{\");\n\n      if (isArray) {\n        startToken = \"[\";\n        endToken = \"]\";\n      }\n\n      const internal = state.sliceDoc(range.from, range.to);\n      const toParse = startToken + internal + endToken;\n\n      try {\n        const parsed = JSON.parse(toParse);\n        count = Object.keys(parsed).length;\n      } catch {\n        /* empty */\n      }\n\n      if (count !== undefined) {\n        const label = isArray ? \"item\" : \"key\";\n        return pluralizeCount(label, count);\n      }\n    },\n  }),\n  indentOnInput(),\n  rectangularSelection(),\n  crosshairCursor(),\n  bracketMatching(),\n  highlightActiveLineGutter(),\n  keymap.of([...searchKeymap, ...foldKeymap, ...lintKeymap]),\n];\n"
  },
  {
    "path": "src-web/components/core/Editor/filter/extension.ts",
    "content": "import type { Completion, CompletionContext, CompletionResult } from \"@codemirror/autocomplete\";\nimport { autocompletion, startCompletion } from \"@codemirror/autocomplete\";\nimport { LanguageSupport, LRLanguage, syntaxTree } from \"@codemirror/language\";\nimport type { SyntaxNode } from \"@lezer/common\";\nimport { parser } from \"./filter\";\n\nexport interface FieldDef {\n  name: string;\n  // Optional static or dynamic value suggestions for this field\n  values?: string[] | (() => string[]);\n  info?: string;\n}\n\nexport interface FilterOptions {\n  fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]\n}\n\nconst IDENT = /[A-Za-z0-9_/]+$/;\nconst IDENT_ONLY = /^[A-Za-z0-9_/]+$/;\n\nfunction normalizeFields(fields: FieldDef[]): {\n  fieldNames: string[];\n  fieldMap: Record<string, { values?: string[] | (() => string[]); info?: string }>;\n} {\n  const fieldNames: string[] = [];\n  const fieldMap: Record<string, { values?: string[] | (() => string[]); info?: string }> = {};\n  for (const f of fields) {\n    fieldNames.push(f.name);\n    fieldMap[f.name] = { values: f.values, info: f.info };\n  }\n  return { fieldNames, fieldMap };\n}\n\nfunction wordBefore(doc: string, pos: number): { from: number; to: number; text: string } | null {\n  const upto = doc.slice(0, pos);\n  const m = upto.match(IDENT);\n  if (!m) return null;\n  const from = pos - m[0].length;\n  return { from, to: pos, text: m[0] };\n}\n\nfunction inPhrase(ctx: CompletionContext): boolean {\n  // Lezer node names from your grammar: Phrase is the quoted token\n  let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);\n  while (n) {\n    if (n.name === \"Phrase\") return true;\n    n = n.parent;\n  }\n  return false;\n}\n\n// While typing an incomplete quote, there's no Phrase token yet.\nfunction inUnclosedQuote(doc: string, pos: number): boolean {\n  let quotes = 0;\n  for (let i = 0; i < pos; i++) {\n    if (doc[i] === '\"' && doc[i - 1] !== \"\\\\\") quotes++;\n  }\n  return quotes % 2 === 1; // odd = inside an open quote\n}\n\n/**\n * Heuristic context detector (works without relying on exact node names):\n * - If there's a ':' after the last whitespace and before the cursor, we're in a field value.\n * - Otherwise, we're in a field name or bare term position.\n */\nfunction contextInfo(stateDoc: string, pos: number) {\n  const lastColon = stateDoc.lastIndexOf(\":\", pos - 1);\n  const lastBoundary = Math.max(\n    stateDoc.lastIndexOf(\" \", pos - 1),\n    stateDoc.lastIndexOf(\"\\t\", pos - 1),\n    stateDoc.lastIndexOf(\"\\n\", pos - 1),\n    stateDoc.lastIndexOf(\"(\", pos - 1),\n    stateDoc.lastIndexOf(\")\", pos - 1),\n  );\n\n  const inValue = lastColon > lastBoundary;\n\n  let fieldName: string | null = null;\n  let emptyAfterColon = false;\n\n  if (inValue) {\n    // word before the colon = field name\n    const beforeColon = stateDoc.slice(0, lastColon);\n    const m = beforeColon.match(IDENT);\n    fieldName = m ? m[0] : null;\n\n    // nothing (or only spaces) typed after the colon?\n    const after = stateDoc.slice(lastColon + 1, pos);\n    emptyAfterColon = after.length === 0 || /^\\s+$/.test(after);\n  }\n\n  return { inValue, fieldName, lastColon, emptyAfterColon };\n}\n\n/** Build a completion list for field names */\nfunction fieldNameCompletions(fieldNames: string[]): Completion[] {\n  return fieldNames.map((name) => ({\n    label: name,\n    type: \"property\",\n    apply: (view, _completion, from, to) => {\n      // Insert \"name:\" (leave cursor right after colon)\n      view.dispatch({\n        changes: { from, to, insert: `${name}:` },\n        selection: { anchor: from + name.length + 1 },\n      });\n      startCompletion(view);\n    },\n  }));\n}\n\n/** Build a completion list for field values (if provided) */\nfunction fieldValueCompletions(\n  def: { values?: string[] | (() => string[]); info?: string } | undefined,\n): Completion[] | null {\n  if (!def || !def.values) return null;\n  const vals = Array.isArray(def.values) ? def.values : def.values();\n  return vals.map((v) => ({\n    label: v.match(IDENT_ONLY) ? v : `\"${v}\"`,\n    displayLabel: v,\n    type: \"constant\",\n  }));\n}\n\n/** The main completion source */\nfunction makeCompletionSource(opts: FilterOptions) {\n  const { fieldNames, fieldMap } = normalizeFields(opts.fields ?? []);\n  return (ctx: CompletionContext): CompletionResult | null => {\n    const { state, pos } = ctx;\n    const doc = state.doc.toString();\n\n    if (inPhrase(ctx) || inUnclosedQuote(doc, pos)) {\n      return null;\n    }\n\n    const w = wordBefore(doc, pos);\n    const from = w?.from ?? pos;\n    const to = pos;\n\n    const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);\n\n    // In field value position\n    if (inValue && fieldName) {\n      const valDefs = fieldMap[fieldName];\n      const vals = fieldValueCompletions(valDefs);\n\n      // If user hasn't typed a value char yet:\n      // - Show value suggestions if available\n      // - Otherwise show nothing (no fallback to field names)\n      if (emptyAfterColon) {\n        if (vals?.length) {\n          return { from, to, options: vals, filter: true };\n        }\n        return null; // <-- key change: do not suggest fields here\n      }\n\n      // User started typing a value; filter value suggestions (if any)\n      if (vals?.length) {\n        return { from, to, options: vals, filter: true };\n      }\n      // No specific values: also show nothing (keeps UI quiet)\n      return null;\n    }\n\n    // Not in a value: suggest field names (and maybe boolean ops)\n    const options: Completion[] = fieldNameCompletions(fieldNames);\n\n    return { from, to, options, filter: true };\n  };\n}\n\nconst language = LRLanguage.define({\n  name: \"filter\",\n  parser,\n  languageData: {\n    autocompletion: {},\n  },\n});\n\n/** Public extension */\nexport function filter(options: FilterOptions) {\n  const source = makeCompletionSource(options);\n  return new LanguageSupport(language, [autocompletion({ override: [source] })]);\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/filter/filter.grammar",
    "content": "@top Query { Expr }\n\n@skip { space+ }\n@tokens {\n  space { std.whitespace+ }\n\n  LParen { \"(\" }\n  RParen { \")\" }\n  Colon  { \":\" }\n  Not  { \"-\" | \"NOT\" }\n\n  // Keywords (case-insensitive)\n  And    { \"AND\" }\n  Or     { \"OR\" }\n\n  // \"quoted phrase\" with simple escapes: \\\" and \\\\\n  Phrase { '\"' (![\"\\\\] | \"\\\\\" _)* '\"' }\n\n  // field/word characters (keep generous for URLs/paths)\n  Word { $[A-Za-z0-9_]+ }\n\n  @precedence { Not, And, Or, Word }\n}\n\n@detectDelim\n\n// Precedence: NOT (highest) > AND > OR (lowest)\n// We also allow implicit AND in your parser/evaluator, but for highlighting,\n// this grammar parses explicit AND/OR/NOT + adjacency as a sequence (Seq).\nExpr {\n  OrExpr\n}\n\nOrExpr {\n  AndExpr (Or AndExpr)*\n}\n\nAndExpr {\n  Unary (And Unary | Unary)*  // allow implicit AND by adjacency: Unary Unary\n}\n\nUnary {\n  Not Unary\n| Primary\n}\n\nPrimary {\n  Group\n| Field\n| Phrase\n| Term\n}\n\nGroup {\n  LParen Expr RParen\n}\n\nField {\n  FieldName Colon FieldValue\n}\n\nFieldName {\n  Word\n}\n\nFieldValue {\n  Phrase\n| Term\n}\n\nTerm {\n  Word\n}\n\n@external propSource highlight from \"./highlight\"\n"
  },
  {
    "path": "src-web/components/core/Editor/filter/filter.ts",
    "content": "/* oxlint-disable */\n// This file was generated by lezer-generator. You probably shouldn't edit it.\nimport { LRParser } from \"@lezer/lr\";\nimport { highlight } from \"./highlight\";\nexport const parser = LRParser.deserialize({\n  version: 14,\n  states:\n    \"%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#mQPO,58zOVQPO'#CrO#zQPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p\",\n  stateData:\n    \"$]~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~O]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~\",\n  goto: \"#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne\",\n  nodeNames:\n    \"⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or\",\n  maxTerm: 25,\n  nodeProps: [\n    [\"openedBy\", 8, \"LParen\"],\n    [\"closedBy\", 9, \"RParen\"],\n  ],\n  propSources: [highlight],\n  skippedNodes: [0, 20],\n  repeatNodeCount: 3,\n  tokenData:\n    \")f~RgX^!jpq!jrs#_xy${yz%Q}!O%V!Q![%[![!]%m!c!d%r!d!p%[!p!q'V!q!r(j!r!}%[#R#S%[#T#o%[#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~!oYi~X^!jpq!j#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~#bVOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u<%lO#_~#|O`~~$PRO;'S#_;'S;=`$Y;=`O#_~$]WOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u;=`<%l#_<%lO#_~$xP;=`<%l#_~%QOX~~%VOW~~%[OU~~%aS]~!Q![%[!c!}%[#R#S%[#T#o%[~%rO^~~%wU]~!Q![%[!c!p%[!p!q&Z!q!}%[#R#S%[#T#o%[~&`U]~!Q![%[!c!f%[!f!g&r!g!}%[#R#S%[#T#o%[~&ySb~]~!Q![%[!c!}%[#R#S%[#T#o%[~'[U]~!Q![%[!c!q%[!q!r'n!r!}%[#R#S%[#T#o%[~'sU]~!Q![%[!c!v%[!v!w(V!w!}%[#R#S%[#T#o%[~(^SU~]~!Q![%[!c!}%[#R#S%[#T#o%[~(oU]~!Q![%[!c!t%[!t!u)R!u!}%[#R#S%[#T#o%[~)YSc~]~!Q![%[!c!}%[#R#S%[#T#o%[\",\n  tokenizers: [0],\n  topRules: { Query: [0, 1] },\n  tokenPrec: 145,\n});\n"
  },
  {
    "path": "src-web/components/core/Editor/filter/highlight.ts",
    "content": "import { styleTags, tags as t } from \"@lezer/highlight\";\n\nexport const highlight = styleTags({\n  // Boolean operators\n  And: t.operatorKeyword,\n  Or: t.operatorKeyword,\n  Not: t.operatorKeyword,\n\n  // Structural punctuation\n  LParen: t.paren,\n  RParen: t.paren,\n  Colon: t.punctuation,\n  Minus: t.operator,\n\n  // Literals\n  Phrase: t.string, // \"quoted string\"\n\n  // Fields\n  \"FieldName/Word\": t.attributeName,\n  \"FieldValue/Term/Word\": t.attributeValue,\n});\n"
  },
  {
    "path": "src-web/components/core/Editor/filter/query.ts",
    "content": "// query.ts\n// A tiny query language parser with NOT/AND/OR, parentheses, phrases, negation, and field:value.\n\nimport { fuzzyMatch } from \"fuzzbunny\";\n/////////////////////////\n// AST\n/////////////////////////\n\nexport type Ast =\n  | { type: \"Term\"; value: string } // foo\n  | { type: \"Phrase\"; value: string } // \"hi there\"\n  | { type: \"Field\"; field: string; value: string } // method:POST or title:\"exact phrase\"\n  | { type: \"Not\"; node: Ast } // -foo or NOT foo\n  | { type: \"And\"; left: Ast; right: Ast } // a AND b\n  | { type: \"Or\"; left: Ast; right: Ast }; // a OR b\n\n/////////////////////////\n// Tokenizer\n/////////////////////////\ntype Tok =\n  | { kind: \"LPAREN\" }\n  | { kind: \"RPAREN\" }\n  | { kind: \"AND\" }\n  | { kind: \"OR\" }\n  | { kind: \"NOT\" } // explicit NOT\n  | { kind: \"MINUS\" } // unary minus before term/phrase/paren group\n  | { kind: \"COLON\" }\n  | { kind: \"WORD\"; text: string } // bareword (unquoted)\n  | { kind: \"PHRASE\"; text: string } // \"quoted phrase\"\n  | { kind: \"EOF\" };\n\nconst isSpace = (c: string) => /\\s/.test(c);\nconst isIdent = (c: string) => /[A-Za-z0-9_\\-./]/.test(c);\n\nexport function tokenize(input: string): Tok[] {\n  const toks: Tok[] = [];\n  let i = 0;\n  const n = input.length;\n\n  const peek = () => input[i] ?? \"\";\n  const advance = () => input[i++];\n\n  const readWord = () => {\n    let s = \"\";\n    while (i < n && isIdent(peek())) s += advance();\n    return s;\n  };\n\n  const readPhrase = () => {\n    // assumes current char is opening quote\n    advance(); // consume opening \"\n    let s = \"\";\n    while (i < n) {\n      const c = advance();\n      if (c === `\"`) break;\n      if (c === \"\\\\\" && i < n) {\n        // escape \\\" and \\\\ (simple)\n        const next = advance();\n        s += next;\n      } else {\n        s += c;\n      }\n    }\n    return s;\n  };\n\n  while (i < n) {\n    const c = peek();\n\n    if (isSpace(c)) {\n      i++;\n      continue;\n    }\n\n    if (c === \"(\") {\n      toks.push({ kind: \"LPAREN\" });\n      i++;\n      continue;\n    }\n    if (c === \")\") {\n      toks.push({ kind: \"RPAREN\" });\n      i++;\n      continue;\n    }\n    if (c === \":\") {\n      toks.push({ kind: \"COLON\" });\n      i++;\n      continue;\n    }\n    if (c === `\"`) {\n      const text = readPhrase();\n      toks.push({ kind: \"PHRASE\", text });\n      continue;\n    }\n    if (c === \"-\") {\n      toks.push({ kind: \"MINUS\" });\n      i++;\n      continue;\n    }\n\n    // WORD / AND / OR / NOT\n    if (isIdent(c)) {\n      const w = readWord();\n      const upper = w.toUpperCase();\n      if (upper === \"AND\") toks.push({ kind: \"AND\" });\n      else if (upper === \"OR\") toks.push({ kind: \"OR\" });\n      else if (upper === \"NOT\") toks.push({ kind: \"NOT\" });\n      else toks.push({ kind: \"WORD\", text: w });\n      continue;\n    }\n\n    // Unknown char—skip to be forgiving\n    i++;\n  }\n\n  toks.push({ kind: \"EOF\" });\n  return toks;\n}\n\nclass Parser {\n  private i = 0;\n  constructor(private toks: Tok[]) {}\n\n  private peek(): Tok {\n    return this.toks[this.i] ?? { kind: \"EOF\" };\n  }\n  private advance(): Tok {\n    return this.toks[this.i++] ?? { kind: \"EOF\" };\n  }\n  private at(kind: Tok[\"kind\"]) {\n    return this.peek().kind === kind;\n  }\n\n  // Top-level: parse OR-precedence chain, allowing implicit AND.\n  parse(): Ast | null {\n    if (this.at(\"EOF\")) return null;\n    const expr = this.parseOr();\n    if (!this.at(\"EOF\")) {\n      // Optionally, consume remaining tokens or throw\n    }\n    return expr;\n  }\n\n  // Precedence: NOT (highest), AND, OR (lowest)\n  private parseOr(): Ast {\n    let node = this.parseAnd();\n    while (this.at(\"OR\")) {\n      this.advance();\n      const rhs = this.parseAnd();\n      node = { type: \"Or\", left: node, right: rhs };\n    }\n    return node;\n  }\n\n  private parseAnd(): Ast {\n    let node = this.parseUnary();\n    // Implicit AND: if next token starts a primary, treat as AND.\n    while (this.at(\"AND\") || this.startsPrimary()) {\n      if (this.at(\"AND\")) this.advance();\n      const rhs = this.parseUnary();\n      node = { type: \"And\", left: node, right: rhs };\n    }\n    return node;\n  }\n\n  private parseUnary(): Ast {\n    if (this.at(\"NOT\") || this.at(\"MINUS\")) {\n      this.advance();\n      const node = this.parseUnary();\n      return { type: \"Not\", node };\n    }\n    return this.parsePrimaryOrField();\n  }\n\n  private startsPrimary(): boolean {\n    const k = this.peek().kind;\n    return k === \"WORD\" || k === \"PHRASE\" || k === \"LPAREN\" || k === \"MINUS\" || k === \"NOT\";\n  }\n\n  private parsePrimaryOrField(): Ast {\n    // Parenthesized group\n    if (this.at(\"LPAREN\")) {\n      this.advance();\n      const inside = this.parseOr();\n      // if (!this.at('RPAREN')) throw new Error(\"Missing closing ')'\");\n      this.advance();\n      return inside;\n    }\n\n    // Phrase\n    if (this.at(\"PHRASE\")) {\n      const t = this.advance() as Extract<Tok, { kind: \"PHRASE\" }>;\n      return { type: \"Phrase\", value: t.text };\n    }\n\n    // Field or bare word\n    if (this.at(\"WORD\")) {\n      const wordTok = this.advance() as Extract<Tok, { kind: \"WORD\" }>;\n\n      if (this.at(\"COLON\")) {\n        // field:value or field:\"phrase\"\n        this.advance(); // :\n        let value: string;\n        if (this.at(\"PHRASE\")) {\n          const p = this.advance() as Extract<Tok, { kind: \"PHRASE\" }>;\n          value = p.text;\n        } else if (this.at(\"WORD\")) {\n          const w = this.advance() as Extract<Tok, { kind: \"WORD\" }>;\n          value = w.text;\n        } else {\n          // Anything else after colon is treated literally as a single Term token.\n          const t = this.advance();\n          value = tokText(t);\n        }\n        return { type: \"Field\", field: wordTok.text, value };\n      }\n\n      // plain term\n      return { type: \"Term\", value: wordTok.text };\n    }\n\n    const w = this.advance() as Extract<Tok, { kind: \"WORD\" }>;\n    return { type: \"Phrase\", value: \"text\" in w ? w.text : \"\" };\n  }\n}\n\nfunction tokText(t: Tok): string {\n  if (\"text\" in t) return t.text;\n\n  switch (t.kind) {\n    case \"COLON\":\n      return \":\";\n    case \"LPAREN\":\n      return \"(\";\n    case \"RPAREN\":\n      return \")\";\n    default:\n      return \"\";\n  }\n}\n\nexport function parseQuery(q: string): Ast | null {\n  if (q.trim() === \"\") return null;\n  const toks = tokenize(q);\n  const parser = new Parser(toks);\n  return parser.parse();\n}\n\nexport type Doc = {\n  text?: string;\n  fields?: Record<string, unknown>;\n};\n\ntype Technique = \"substring\" | \"fuzzy\" | \"strict\";\n\nfunction includes(hay: string | undefined, needle: string, technique: Technique): boolean {\n  if (!hay || !needle) return false;\n  if (technique === \"strict\") return hay === needle;\n  if (technique === \"fuzzy\") return !!fuzzyMatch(hay, needle);\n  return hay.indexOf(needle) !== -1;\n}\n\nexport function evaluate(ast: Ast | null, doc: Doc): boolean {\n  if (!ast) return true; // Match everything if no query is provided\n\n  const text = (doc.text ?? \"\").toLowerCase();\n  const fieldsNorm: Record<string, string[]> = {};\n\n  for (const [k, v] of Object.entries(doc.fields ?? {})) {\n    if (!(typeof v === \"string\" || Array.isArray(v))) continue;\n    fieldsNorm[k.toLowerCase()] = Array.isArray(v)\n      ? v.filter((v) => typeof v === \"string\").map((s) => s.toLowerCase())\n      : [String(v ?? \"\").toLowerCase()];\n  }\n\n  const evalNode = (node: Ast): boolean => {\n    switch (node.type) {\n      case \"Term\":\n        return includes(text, node.value.toLowerCase(), \"fuzzy\");\n      case \"Phrase\":\n        // Quoted phrases match exactly\n        return includes(text, node.value.toLowerCase(), \"substring\");\n      case \"Field\": {\n        const vals = fieldsNorm[node.field.toLowerCase()] ?? [];\n        if (vals.length === 0) return false;\n        return vals.some((v) => includes(v, node.value.toLowerCase(), \"substring\"));\n      }\n      case \"Not\":\n        return !evalNode(node.node);\n      case \"And\":\n        return evalNode(node.left) && evalNode(node.right);\n      case \"Or\":\n        return evalNode(node.left) || evalNode(node.right);\n    }\n  };\n\n  return evalNode(ast);\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/genericCompletion.ts",
    "content": "import type { CompletionContext } from \"@codemirror/autocomplete\";\nimport type { GenericCompletionOption } from \"@yaakapp-internal/plugins\";\nimport { defaultBoost } from \"./twig/completion\";\n\nexport interface GenericCompletionConfig {\n  minMatch?: number;\n  options: GenericCompletionOption[];\n}\n\n/**\n * Complete options, always matching until the start of the line\n */\nexport function genericCompletion(config?: GenericCompletionConfig) {\n  if (config == null) return [];\n\n  const { minMatch = 1, options } = config;\n\n  return function completions(context: CompletionContext) {\n    const toMatch = context.matchBefore(/.*/);\n\n    // Only match if we're at the start of the line\n    if (toMatch === null || toMatch.from > 0) return null;\n\n    const matchedMinimumLength = toMatch.to - toMatch.from >= minMatch;\n    if (!matchedMinimumLength && !context.explicit) return null;\n\n    const optionsWithoutExactMatches = options\n      .filter((o) => o.label !== toMatch.text)\n      .map((o) => ({\n        ...o,\n        boost: defaultBoost(o),\n      }));\n    return {\n      validFor: () => true, // Not really sure why this is all it needs\n      from: toMatch.from,\n      options: optionsWithoutExactMatches,\n    };\n  };\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/hyperlink/extension.ts",
    "content": "import type { DecorationSet, ViewUpdate } from \"@codemirror/view\";\nimport { Decoration, EditorView, hoverTooltip, MatchDecorator, ViewPlugin } from \"@codemirror/view\";\nimport { activeWorkspaceIdAtom } from \"../../../../hooks/useActiveWorkspace\";\nimport { copyToClipboard } from \"../../../../lib/copy\";\nimport { createRequestAndNavigate } from \"../../../../lib/createRequestAndNavigate\";\nimport { jotaiStore } from \"../../../../lib/jotai\";\n\nconst REGEX =\n  /(https?:\\/\\/([-a-zA-Z0-9@:%._+*~#=]{1,256})+(\\.[a-zA-Z0-9()]{1,6})?\\b([-a-zA-Z0-9()@:%_+*.~#?&/={}[\\]]*))/g;\n\nconst tooltip = hoverTooltip(\n  (view, pos, side) => {\n    const { from, text } = view.state.doc.lineAt(pos);\n    let match: RegExpExecArray | null;\n    let found: { start: number; end: number } | null = null;\n\n    // oxlint-disable-next-line no-cond-assign\n    while ((match = REGEX.exec(text))) {\n      const start = from + match.index;\n      const end = start + match[0].length;\n\n      if (pos >= start && pos <= end) {\n        found = { start, end };\n        break;\n      }\n    }\n\n    if (found == null) {\n      return null;\n    }\n\n    if ((found.start === pos && side < 0) || (found.end === pos && side > 0)) {\n      return null;\n    }\n\n    return {\n      pos: found.start,\n      end: found.end,\n      create() {\n        const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n        const link = text.substring(found?.start - from, found?.end - from);\n        const dom = document.createElement(\"div\");\n\n        const $open = document.createElement(\"a\");\n        $open.textContent = \"Open in browser\";\n        $open.href = link;\n        $open.target = \"_blank\";\n        $open.rel = \"noopener noreferrer\";\n\n        const $copy = document.createElement(\"button\");\n        $copy.textContent = \"Copy to clipboard\";\n        $copy.addEventListener(\"click\", () => {\n          copyToClipboard(link);\n        });\n\n        const $create = document.createElement(\"button\");\n        $create.textContent = \"Create new request\";\n        $create.addEventListener(\"click\", async () => {\n          await createRequestAndNavigate({\n            model: \"http_request\",\n            workspaceId: workspaceId ?? \"n/a\",\n            url: link,\n          });\n        });\n\n        dom.appendChild($open);\n        dom.appendChild($copy);\n        if (workspaceId != null) {\n          dom.appendChild($create);\n        }\n\n        return { dom };\n      },\n    };\n  },\n  {\n    hoverTime: 150,\n  },\n);\n\nconst decorator = () => {\n  const placeholderMatcher = new MatchDecorator({\n    regexp: REGEX,\n    decoration(match, view, matchStartPos) {\n      const matchEndPos = matchStartPos + match[0].length - 1;\n\n      // Don't decorate if the cursor is inside the match\n      for (const r of view.state.selection.ranges) {\n        if (r.from > matchStartPos && r.to <= matchEndPos) {\n          return Decoration.replace({});\n        }\n      }\n\n      const groupMatch = match[1];\n      if (groupMatch == null) {\n        // Should never happen, but make TS happy\n        console.warn(\"Group match was empty\", match);\n        return Decoration.replace({});\n      }\n\n      return Decoration.mark({\n        class: \"hyperlink-widget\",\n      });\n    },\n  });\n\n  return ViewPlugin.fromClass(\n    class {\n      decorations: DecorationSet;\n\n      constructor(view: EditorView) {\n        this.decorations = placeholderMatcher.createDeco(view);\n      }\n\n      update(update: ViewUpdate) {\n        this.decorations = placeholderMatcher.updateDeco(update, this.decorations);\n      }\n    },\n    {\n      decorations: (instance) => instance.decorations,\n      provide: (plugin) =>\n        EditorView.bidiIsolatedRanges.of((view) => {\n          return view.plugin(plugin)?.decorations || Decoration.none;\n        }),\n    },\n  );\n};\n\nexport const hyperlink = [tooltip, decorator()];\n"
  },
  {
    "path": "src-web/components/core/Editor/json-lint.ts",
    "content": "import type { Diagnostic } from \"@codemirror/lint\";\nimport type { EditorView } from \"@codemirror/view\";\nimport { parse as jsonLintParse } from \"@prantlf/jsonlint\";\n\nconst TEMPLATE_SYNTAX_REGEX = /\\$\\{\\[[\\s\\S]*?]}/g;\n\ninterface JsonLintOptions {\n  allowComments?: boolean;\n  allowTrailingCommas?: boolean;\n}\n\nexport function jsonParseLinter(options?: JsonLintOptions) {\n  return (view: EditorView): Diagnostic[] => {\n    try {\n      const doc = view.state.doc.toString();\n      // We need lint to not break on stuff like {\"foo:\" ${[ ... ]}} so we'll replace all template\n      // syntax with repeating `1` characters, so it's valid JSON and the position is still correct.\n      const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => \"1\".repeat(m.length));\n      jsonLintParse(escapedDoc, {\n        mode: (options?.allowComments ?? true) ? \"cjson\" : \"json\",\n        ignoreTrailingCommas: options?.allowTrailingCommas ?? false,\n      });\n      // oxlint-disable-next-line no-explicit-any\n    } catch (err: any) {\n      if (!(\"location\" in err)) {\n        return [];\n      }\n\n      // const line = location?.start?.line;\n      // const column = location?.start?.column;\n      if (err.location.start.offset) {\n        return [\n          {\n            from: err.location.start.offset,\n            to: err.location.start.offset,\n            severity: \"error\",\n            message: err.message,\n          },\n        ];\n      }\n    }\n    return [];\n  };\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/pairs/extension.ts",
    "content": "import { LanguageSupport, LRLanguage } from \"@codemirror/language\";\nimport { parser } from \"./pairs\";\n\nconst language = LRLanguage.define({\n  name: \"pairs\",\n  parser,\n  languageData: {},\n});\n\nexport function pairs() {\n  return new LanguageSupport(language, []);\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/pairs/highlight.ts",
    "content": "import { styleTags, tags as t } from \"@lezer/highlight\";\n\nexport const highlight = styleTags({\n  Sep: t.bracket,\n  Key: t.attributeName,\n  Value: t.string,\n});\n"
  },
  {
    "path": "src-web/components/core/Editor/pairs/pairs.grammar",
    "content": "@top pairs { (Key Sep Value \"\\n\")* }\n\n@tokens {\n  Sep { \":\" }\n  Key { \":\"? ![:]+ }\n  Value { ![\\n]+ }\n}\n\n@external propSource highlight from \"./highlight\"\n"
  },
  {
    "path": "src-web/components/core/Editor/pairs/pairs.terms.ts",
    "content": "// This file was generated by lezer-generator. You probably shouldn't edit it.\nexport const pairs = 1,\n  Key = 2,\n  Sep = 3,\n  Value = 4;\n"
  },
  {
    "path": "src-web/components/core/Editor/pairs/pairs.ts",
    "content": "// This file was generated by lezer-generator. You probably shouldn't edit it.\nimport { LRParser } from \"@lezer/lr\";\nimport { highlight } from \"./highlight\";\nexport const parser = LRParser.deserialize({\n  version: 14,\n  states: \"zQQOPOOOVOQO'#CaQQOPOOO[OSO,58{OOOO-E6_-E6_OaOQO1G.gOOOO7+$R7+$R\",\n  stateData: \"f~OQPO~ORRO~OSTO~OVUO~O\",\n  goto: \"]UPPPPPVQQORSQ\",\n  nodeNames: \"⚠ pairs Key Sep Value\",\n  maxTerm: 7,\n  propSources: [highlight],\n  skippedNodes: [0],\n  repeatNodeCount: 1,\n  tokenData:\n    \"$]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#vVRQSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOh\",\n  tokenizers: [0, 1, 2],\n  topRules: { pairs: [0, 1] },\n  tokenPrec: 0,\n  termNames: {\n    \"0\": \"⚠\",\n    \"1\": \"@top\",\n    \"2\": \"Key\",\n    \"3\": \"Sep\",\n    \"4\": \"Value\",\n    \"5\": '(Key Sep Value \"\\\\n\")+',\n    \"6\": \"␄\",\n    \"7\": '\"\\\\n\"',\n  },\n});\n"
  },
  {
    "path": "src-web/components/core/Editor/searchMatchCount.ts",
    "content": "import { getSearchQuery, searchPanelOpen } from \"@codemirror/search\";\nimport type { Extension } from \"@codemirror/state\";\nimport { type EditorView, ViewPlugin, type ViewUpdate } from \"@codemirror/view\";\n\n/**\n * A CodeMirror extension that displays the total number of search matches\n * inside the built-in search panel.\n */\nexport function searchMatchCount(): Extension {\n  return ViewPlugin.fromClass(\n    class {\n      private countEl: HTMLElement | null = null;\n\n      constructor(private view: EditorView) {\n        this.updateCount();\n      }\n\n      update(update: ViewUpdate) {\n        // Recompute when doc changes, search state changes, or selection moves\n        const query = getSearchQuery(update.state);\n        const prevQuery = getSearchQuery(update.startState);\n        const open = searchPanelOpen(update.state);\n        const prevOpen = searchPanelOpen(update.startState);\n\n        if (update.docChanged || update.selectionSet || !query.eq(prevQuery) || open !== prevOpen) {\n          this.updateCount();\n        }\n      }\n\n      private updateCount() {\n        const state = this.view.state;\n        const open = searchPanelOpen(state);\n        const query = getSearchQuery(state);\n\n        if (!open) {\n          this.removeCountEl();\n          return;\n        }\n\n        this.ensureCountEl();\n\n        if (!query.search) {\n          if (this.countEl) {\n            this.countEl.textContent = \"0/0\";\n          }\n          return;\n        }\n\n        const selection = state.selection.main;\n        let count = 0;\n        let currentIndex = 0;\n        const MAX_COUNT = 9999;\n        const cursor = query.getCursor(state);\n        for (let result = cursor.next(); !result.done; result = cursor.next()) {\n          count++;\n          const match = result.value;\n          if (match.from <= selection.from && match.to >= selection.to) {\n            currentIndex = count;\n          }\n          if (count > MAX_COUNT) break;\n        }\n\n        if (this.countEl) {\n          if (count > MAX_COUNT) {\n            this.countEl.textContent = `${MAX_COUNT}+`;\n          } else if (count === 0) {\n            this.countEl.textContent = \"0/0\";\n          } else if (currentIndex > 0) {\n            this.countEl.textContent = `${currentIndex}/${count}`;\n          } else {\n            this.countEl.textContent = `0/${count}`;\n          }\n        }\n      }\n\n      private ensureCountEl() {\n        // Find the search panel in the editor DOM\n        const panel = this.view.dom.querySelector(\".cm-search\");\n        if (!panel) {\n          this.countEl = null;\n          return;\n        }\n\n        if (this.countEl && this.countEl.parentElement === panel) {\n          return; // Already attached\n        }\n\n        this.countEl = document.createElement(\"span\");\n        this.countEl.className = \"cm-search-match-count\";\n\n        // Reorder: insert prev button, then next button, then count after the search input\n        const searchInput = panel.querySelector(\"input\");\n        const prevBtn = panel.querySelector('button[name=\"prev\"]');\n        const nextBtn = panel.querySelector('button[name=\"next\"]');\n        if (searchInput && searchInput.parentElement === panel) {\n          searchInput.after(this.countEl);\n          if (prevBtn) this.countEl.after(prevBtn);\n          if (nextBtn && prevBtn) prevBtn.after(nextBtn);\n        } else {\n          panel.prepend(this.countEl);\n        }\n      }\n\n      private removeCountEl() {\n        if (this.countEl) {\n          this.countEl.remove();\n          this.countEl = null;\n        }\n      }\n\n      destroy() {\n        this.removeCountEl();\n      }\n    },\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/singleLine.ts",
    "content": "import type { Extension, TransactionSpec } from \"@codemirror/state\";\nimport { EditorSelection, EditorState, Transaction } from \"@codemirror/state\";\n\n/**\n * A CodeMirror extension that forces single-line input by stripping\n * all newline characters from user input, including pasted content.\n *\n * This extension uses a transaction filter to intercept user input,\n * removes any newline characters, and adjusts the selection to the end\n * of the inserted text.\n *\n * IME composition events are ignored to preserve proper input behavior\n * for non-Latin languages.\n *\n * @returns A CodeMirror extension that enforces single-line editing.\n */\nexport function singleLineExtensions(): Extension {\n  return EditorState.transactionFilter.of(\n    (tr: Transaction): TransactionSpec | readonly TransactionSpec[] => {\n      if (!tr.isUserEvent(\"input\") || tr.isUserEvent(\"input.type.compose\")) return tr;\n\n      const changes: { from: number; to: number; insert: string }[] = [];\n\n      tr.changes.iterChanges((_fromA, toA, fromB, _toB, inserted) => {\n        let insert = \"\";\n        for (const line of inserted.iterLines()) {\n          insert += line.replace(/\\n/g, \"\");\n        }\n\n        if (insert !== inserted.toString()) {\n          changes.push({ from: fromB, to: toA, insert });\n        }\n      });\n\n      const lastChange = changes[changes.length - 1];\n      if (lastChange == null) return tr;\n\n      const selection = EditorSelection.cursor(lastChange.from + lastChange.insert.length);\n\n      return {\n        changes,\n        selection,\n        userEvent: tr.annotation(Transaction.userEvent) ?? undefined,\n      };\n    },\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/text/extension.ts",
    "content": "import { LanguageSupport, LRLanguage } from \"@codemirror/language\";\nimport { parser } from \"./text\";\n\nexport const textLanguage = LRLanguage.define({\n  name: \"text\",\n  parser,\n  languageData: {},\n});\n\nexport function text() {\n  return new LanguageSupport(textLanguage);\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/text/text.grammar",
    "content": "@top Template { Text }\n\n@tokens {\n  Text { ![]+ }\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/text/text.terms.ts",
    "content": "// This file was generated by lezer-generator. You probably shouldn't edit it.\nexport const Template = 1,\n  Text = 2;\n"
  },
  {
    "path": "src-web/components/core/Editor/text/text.ts",
    "content": "// This file was generated by lezer-generator. You probably shouldn't edit it.\nimport { LRParser } from \"@lezer/lr\";\nexport const parser = LRParser.deserialize({\n  version: 14,\n  states: \"[OQOPOOQOOOOO\",\n  stateData: \"V~OQPO~O\",\n  goto: \"QPP\",\n  nodeNames: \"⚠ Template Text\",\n  maxTerm: 3,\n  skippedNodes: [0],\n  repeatNodeCount: 0,\n  tokenData: \"p~RRO;'S[;'S;=`j<%lO[~aRQ~O;'S[;'S;=`j<%lO[~mP;=`<%l[\",\n  tokenizers: [0],\n  topRules: { Template: [0, 1] },\n  tokenPrec: 0,\n});\n"
  },
  {
    "path": "src-web/components/core/Editor/timeline/extension.ts",
    "content": "import { LanguageSupport, LRLanguage } from \"@codemirror/language\";\nimport { parser } from \"./timeline\";\n\nexport const timelineLanguage = LRLanguage.define({\n  name: \"timeline\",\n  parser,\n  languageData: {},\n});\n\nexport function timeline() {\n  return new LanguageSupport(timelineLanguage);\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/timeline/highlight.ts",
    "content": "import { styleTags, tags as t } from \"@lezer/highlight\";\n\nexport const highlight = styleTags({\n  OutgoingText: t.propertyName, // > lines - primary color (matches timeline icons)\n  IncomingText: t.tagName, // < lines - info color (matches timeline icons)\n  InfoText: t.comment, // * lines - subtle color (matches timeline icons)\n});\n"
  },
  {
    "path": "src-web/components/core/Editor/timeline/timeline.grammar",
    "content": "@top Timeline { line* }\n\nline { OutgoingLine | IncomingLine | InfoLine | PlainLine }\n\n@skip {} {\n  OutgoingLine { OutgoingText Newline }\n  IncomingLine { IncomingText Newline }\n  InfoLine { InfoText Newline }\n  PlainLine { PlainText Newline }\n}\n\n@tokens {\n  OutgoingText { \"> \" ![\\n]* }\n  IncomingText { \"< \" ![\\n]* }\n  InfoText { \"* \" ![\\n]* }\n  PlainText { ![\\n]+ }\n  Newline { \"\\n\" }\n  @precedence { OutgoingText, IncomingText, InfoText, PlainText }\n}\n\n@external propSource highlight from \"./highlight\"\n"
  },
  {
    "path": "src-web/components/core/Editor/timeline/timeline.terms.ts",
    "content": "// This file was generated by lezer-generator. You probably shouldn't edit it.\nexport const Timeline = 1,\n  OutgoingLine = 2,\n  OutgoingText = 3,\n  Newline = 4,\n  IncomingLine = 5,\n  IncomingText = 6,\n  InfoLine = 7,\n  InfoText = 8,\n  PlainLine = 9,\n  PlainText = 10;\n"
  },
  {
    "path": "src-web/components/core/Editor/timeline/timeline.ts",
    "content": "// This file was generated by lezer-generator. You probably shouldn't edit it.\nimport { LRParser } from \"@lezer/lr\";\nimport { highlight } from \"./highlight\";\nexport const parser = LRParser.deserialize({\n  version: 14,\n  states:\n    \"!pQQOPOOO`OPO'#C^OeOPO'#CaOjOPO'#CcOoOPO'#CeOOOO'#Ci'#CiOOOO'#Cg'#CgQQOPOOOOOO,58x,58xOOOO,58{,58{OOOO,58},58}OOOO,59P,59POOOO-E6e-E6e\",\n  stateData: \"z~ORPOUQOWROYSO~OSWO~OSXO~OSYO~OSZO~ORUWYW~\",\n  goto: \"m^PP_PP_P_P_PcPiTTOVQVOR[VTUOV\",\n  nodeNames:\n    \"⚠ Timeline OutgoingLine OutgoingText Newline IncomingLine IncomingText InfoLine InfoText PlainLine PlainText\",\n  maxTerm: 13,\n  propSources: [highlight],\n  skippedNodes: [0],\n  repeatNodeCount: 1,\n  tokenData:\n    \"%h~RZOYtYZ!]Zztz{!b{!^t!^!_#d!_!`t!`!a$f!a;'St;'S;=`!V<%lOt~ySY~OYtZ;'St;'S;=`!V<%lOt~!YP;=`<%lt~!bOS~~!gUY~OYtZptpq!yq;'St;'S;=`!V<%lOt~#QSW~Y~OY!yZ;'S!y;'S;=`#^<%lO!y~#aP;=`<%l!y~#iUY~OYtZptpq#{q;'St;'S;=`!V<%lOt~$SSU~Y~OY#{Z;'S#{;'S;=`$`<%lO#{~$cP;=`<%l#{~$kUY~OYtZptpq$}q;'St;'S;=`!V<%lOt~%USR~Y~OY$}Z;'S$};'S;=`%b<%lO$}~%eP;=`<%l$}\",\n  tokenizers: [0],\n  topRules: { Timeline: [0, 1] },\n  tokenPrec: 36,\n});\n"
  },
  {
    "path": "src-web/components/core/Editor/twig/completion.ts",
    "content": "import type { Completion, CompletionContext } from \"@codemirror/autocomplete\";\nimport { startCompletion } from \"@codemirror/autocomplete\";\nimport type { TemplateFunction } from \"@yaakapp-internal/plugins\";\n\nconst openTag = \"${[ \";\nconst closeTag = \" ]}\";\n\nexport type TwigCompletionOptionVariable = {\n  type: \"variable\";\n};\n\nexport type TwigCompletionOptionNamespace = {\n  type: \"namespace\";\n};\n\nexport type TwigCompletionOptionFunction = TemplateFunction & {\n  type: \"function\";\n};\n\nexport type TwigCompletionOption = (\n  | TwigCompletionOptionFunction\n  | TwigCompletionOptionVariable\n  | TwigCompletionOptionNamespace\n) & {\n  name: string;\n  label: string | HTMLElement;\n  description?: string;\n  onClick: (rawTag: string, startPos: number) => void;\n  value: string | null;\n  invalid?: boolean;\n};\n\nexport interface TwigCompletionConfig {\n  options: TwigCompletionOption[];\n}\n\nconst MIN_MATCH_NAME = 1;\n\nexport function twigCompletion({ options }: TwigCompletionConfig) {\n  return function completions(context: CompletionContext) {\n    const toStartOfName = context.matchBefore(/[\\w_.]*/);\n    const toMatch = toStartOfName ?? null;\n\n    if (toMatch === null) return null;\n\n    const matchLen = toMatch.to - toMatch.from;\n    if (!context.explicit && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {\n      return null;\n    }\n\n    const completions: Completion[] = options\n      .flatMap((o): Completion[] => {\n        const matchSegments = toMatch.text.replace(/^\\$/, \"\").split(\".\");\n        const optionSegments = o.name.split(\".\");\n\n        // If not on the last segment, only complete the namespace\n        if (matchSegments.length < optionSegments.length) {\n          const prefix = optionSegments.slice(0, matchSegments.length).join(\".\");\n          return [\n            {\n              label: `${prefix}.*`,\n              type: \"namespace\",\n              detail: \"namespace\",\n              apply: (view, _completion, from, to) => {\n                const insert = `${prefix}.`;\n                view.dispatch({\n                  changes: { from, to, insert: insert },\n                  selection: { anchor: from + insert.length },\n                });\n                // Leave the autocomplete open so the user can continue typing the rest of the namespace\n                startCompletion(view);\n              },\n            },\n          ];\n        }\n\n        // If on the last segment, wrap the entire tag\n        const inner = o.type === \"function\" ? `${o.name}()` : o.name;\n        return [\n          {\n            label: o.name,\n            info: o.description,\n            detail: o.type,\n            type: o.type === \"variable\" ? \"variable\" : \"function\",\n            apply: (view, _completion, from, to) => {\n              const insert = openTag + inner + closeTag;\n              view.dispatch({\n                changes: { from, to, insert: insert },\n                selection: { anchor: from + insert.length },\n              });\n            },\n          },\n        ];\n      })\n      .filter((v) => v != null);\n\n    const uniqueCompletions = uniqueBy(completions, \"label\");\n    const sortedCompletions = uniqueCompletions.sort((a, b) => {\n      const boostDiff = defaultBoost(b) - defaultBoost(a);\n      if (boostDiff !== 0) return boostDiff;\n      return a.label.localeCompare(b.label);\n    });\n\n    return {\n      matchLen,\n      validFor: () => true, // Not really sure why this is all it needs\n      from: toMatch.from,\n      options: sortedCompletions,\n    };\n  };\n}\n\nexport function uniqueBy<T, K extends keyof T>(arr: T[], key: K): T[] {\n  const map = new Map<T[K], T>();\n  for (const item of arr) {\n    map.set(item[key], item); // overwrites → keeps last\n  }\n  return [...map.values()];\n}\n\nexport function defaultBoost(o: Completion) {\n  if (o.type === \"variable\") return 4;\n  if (o.type === \"constant\") return 3;\n  if (o.type === \"function\") return 2;\n  if (o.type === \"namespace\") return 1;\n  return 0;\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/twig/extension.ts",
    "content": "import type { LanguageSupport } from \"@codemirror/language\";\nimport { LRLanguage } from \"@codemirror/language\";\nimport type { Extension } from \"@codemirror/state\";\nimport { parseMixed } from \"@lezer/common\";\nimport type { WrappedEnvironmentVariable } from \"../../../../hooks/useEnvironmentVariables\";\nimport type { GenericCompletionConfig } from \"../genericCompletion\";\nimport { genericCompletion } from \"../genericCompletion\";\nimport { textLanguage } from \"../text/extension\";\nimport type { TwigCompletionOption } from \"./completion\";\nimport { twigCompletion } from \"./completion\";\nimport { templateTagsPlugin } from \"./templateTags\";\nimport { parser as twigParser } from \"./twig\";\n\nexport function twig({\n  base,\n  environmentVariables,\n  completionOptions,\n  autocomplete,\n  onClickVariable,\n  onClickMissingVariable,\n  extraExtensions,\n}: {\n  base: LanguageSupport;\n  environmentVariables: WrappedEnvironmentVariable[];\n  completionOptions: TwigCompletionOption[];\n  autocomplete?: GenericCompletionConfig;\n  onClickVariable: (option: WrappedEnvironmentVariable, tagValue: string, startPos: number) => void;\n  onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void;\n  onClickPathParameter: (name: string) => void;\n  extraExtensions: Extension[];\n}) {\n  const language = mixLanguage(base);\n\n  const variableOptions: TwigCompletionOption[] =\n    environmentVariables.map((v) => ({\n      name: v.variable.name,\n      value: v.variable.value,\n      type: \"variable\",\n      label: v.variable.name,\n      description: `Inherited from ${v.source}`,\n      onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),\n    })) ?? [];\n\n  const options = [...variableOptions, ...completionOptions];\n  const completions = twigCompletion({ options });\n\n  return [\n    language,\n    base.support,\n    language.data.of({ autocomplete: completions }),\n    base.language.data.of({ autocomplete: completions }),\n    language.data.of({ autocomplete: genericCompletion(autocomplete) }),\n    base.language.data.of({ autocomplete: genericCompletion(autocomplete) }),\n    templateTagsPlugin(options, onClickMissingVariable),\n    ...extraExtensions,\n  ];\n}\n\nconst mixedLanguagesCache: Record<string, LRLanguage> = {};\n\nfunction mixLanguage(base: LanguageSupport): LRLanguage {\n  // It can be slow to mix languages when there are hundreds of editors, so we'll cache them to speed it up\n  const cached = mixedLanguagesCache[base.language.name];\n  if (cached != null) {\n    return cached;\n  }\n\n  const parser = twigParser.configure({\n    wrap: parseMixed((node) => {\n      // If the base language is text, we can overwrite at the top\n      if (base.language.name !== textLanguage.name && !node.type.isTop) {\n        return null;\n      }\n\n      return {\n        parser: base.language.parser,\n        overlay: (node) => node.type.name === \"Text\",\n      };\n    }),\n  });\n\n  const language = LRLanguage.define({ name: \"twig\", parser });\n  mixedLanguagesCache[base.language.name] = language;\n  return language;\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/twig/highlight.ts",
    "content": "import { styleTags, tags as t } from \"@lezer/highlight\";\n\nexport const highlight = styleTags({\n  TagOpen: t.bracket,\n  TagClose: t.bracket,\n  TagContent: t.keyword,\n});\n"
  },
  {
    "path": "src-web/components/core/Editor/twig/pathParameters.ts",
    "content": "import { syntaxTree } from \"@codemirror/language\";\nimport type { Range } from \"@codemirror/state\";\nimport type { DecorationSet, ViewUpdate } from \"@codemirror/view\";\nimport { Decoration, EditorView, ViewPlugin, WidgetType } from \"@codemirror/view\";\n\nclass PathPlaceholderWidget extends WidgetType {\n  readonly #clickListenerCallback: () => void;\n\n  constructor(\n    readonly rawText: string,\n    readonly startPos: number,\n    readonly onClick: () => void,\n  ) {\n    super();\n    this.#clickListenerCallback = () => {\n      this.onClick?.();\n    };\n  }\n\n  eq(other: PathPlaceholderWidget) {\n    return this.startPos === other.startPos && this.rawText === other.rawText;\n  }\n\n  toDOM() {\n    const elt = document.createElement(\"span\");\n    elt.className = \"x-theme-templateTag x-theme-templateTag--secondary template-tag\";\n    elt.textContent = this.rawText;\n    elt.addEventListener(\"click\", this.#clickListenerCallback);\n    return elt;\n  }\n\n  destroy(dom: HTMLElement) {\n    dom.removeEventListener(\"click\", this.#clickListenerCallback);\n    super.destroy(dom);\n  }\n\n  ignoreEvent() {\n    return false;\n  }\n}\n\nfunction pathParameters(\n  view: EditorView,\n  onClickPathParameter: (name: string) => void,\n): DecorationSet {\n  const widgets: Range<Decoration>[] = [];\n  const tree = syntaxTree(view.state);\n  for (const { from, to } of view.visibleRanges) {\n    tree.iterate({\n      from,\n      to,\n      enter(node) {\n        if (node.name === \"Text\") {\n          // Find the `url` node and then jump into it to find the placeholders\n          for (let i = node.from; i < node.to; i++) {\n            const innerTree = syntaxTree(view.state).resolveInner(i);\n            if (innerTree.node.name === \"url\") {\n              innerTree.toTree().iterate({\n                enter(node) {\n                  if (node.name !== \"Placeholder\") return;\n                  const globalFrom = innerTree.node.from + node.from;\n                  const globalTo = innerTree.node.from + node.to;\n                  const rawText = view.state.doc.sliceString(globalFrom, globalTo);\n                  const onClick = () => onClickPathParameter(rawText);\n                  const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);\n                  const deco = Decoration.replace({ widget, inclusive: false });\n                  widgets.push(deco.range(globalFrom, globalTo));\n                },\n              });\n              break;\n            }\n          }\n        }\n      },\n    });\n  }\n\n  // Widgets must be sorted start to end\n  widgets.sort((a, b) => a.from - b.from);\n\n  return Decoration.set(widgets);\n}\n\nexport function pathParametersPlugin(onClickPathParameter: (name: string) => void) {\n  return ViewPlugin.fromClass(\n    class {\n      decorations: DecorationSet;\n\n      constructor(view: EditorView) {\n        this.decorations = pathParameters(view, onClickPathParameter);\n      }\n\n      update(update: ViewUpdate) {\n        this.decorations = pathParameters(update.view, onClickPathParameter);\n      }\n    },\n    {\n      decorations(v) {\n        return v.decorations;\n      },\n      provide(plugin) {\n        return EditorView.atomicRanges.of((view) => {\n          return view.plugin(plugin)?.decorations || Decoration.none;\n        });\n      },\n    },\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/twig/templateTags.ts",
    "content": "import { syntaxTree } from \"@codemirror/language\";\nimport type { Range } from \"@codemirror/state\";\nimport type { DecorationSet, ViewUpdate } from \"@codemirror/view\";\nimport { Decoration, EditorView, ViewPlugin, WidgetType } from \"@codemirror/view\";\nimport type { SyntaxNodeRef } from \"@lezer/common\";\nimport { applyFormInputDefaults, validateTemplateFunctionArgs } from \"@yaakapp-internal/lib\";\nimport type { FormInput, JsonPrimitive, TemplateFunction } from \"@yaakapp-internal/plugins\";\nimport { parseTemplate } from \"@yaakapp-internal/templates\";\nimport type { TwigCompletionOption } from \"./completion\";\nimport { collectArgumentValues } from \"./util\";\n\nclass TemplateTagWidget extends WidgetType {\n  readonly #clickListenerCallback: () => void;\n\n  constructor(\n    readonly option: TwigCompletionOption,\n    readonly rawTag: string,\n    readonly startPos: number,\n  ) {\n    super();\n    this.#clickListenerCallback = () => {\n      this.option.onClick?.(this.rawTag, this.startPos);\n    };\n  }\n\n  eq(other: TemplateTagWidget) {\n    return (\n      this.option.name === other.option.name &&\n      this.option.type === other.option.type &&\n      this.option.value === other.option.value &&\n      this.rawTag === other.rawTag &&\n      this.startPos === other.startPos\n    );\n  }\n\n  toDOM() {\n    const elt = document.createElement(\"span\");\n    elt.className = `x-theme-templateTag template-tag ${\n      this.option.invalid\n        ? \"x-theme-templateTag--danger\"\n        : this.option.type === \"variable\"\n          ? \"x-theme-templateTag--primary\"\n          : \"x-theme-templateTag--info\"\n    }`;\n    elt.title = this.option.invalid ? \"Not Found\" : (this.option.value ?? \"\");\n    elt.setAttribute(\"data-tag-type\", this.option.type);\n    if (typeof this.option.label === \"string\") elt.textContent = this.option.label;\n    else elt.appendChild(this.option.label);\n    elt.addEventListener(\"click\", this.#clickListenerCallback);\n    return elt;\n  }\n\n  destroy(dom: HTMLElement) {\n    dom.removeEventListener(\"click\", this.#clickListenerCallback);\n    super.destroy(dom);\n  }\n\n  ignoreEvent() {\n    return false;\n  }\n}\n\nfunction templateTags(\n  view: EditorView,\n  options: TwigCompletionOption[],\n  onClickMissingVariable: (name: string, rawTag: string, startPos: number) => void,\n): DecorationSet {\n  const widgets: Range<Decoration>[] = [];\n  const tree = syntaxTree(view.state);\n  for (const { from, to } of view.visibleRanges) {\n    tree.iterate({\n      from,\n      to,\n      enter(node) {\n        if (node.name === \"Tag\") {\n          // Don't decorate if the cursor is inside the match\n          if (isSelectionInsideNode(view, node)) return;\n\n          const rawTag = view.state.doc.sliceString(node.from, node.to);\n\n          // TODO: Search `node.tree` instead of using Regex here\n          const inner = rawTag.replace(/^\\$\\{\\[\\s*/, \"\").replace(/\\s*]}$/, \"\");\n          let name = inner.match(/([\\w.]+)[(]/)?.[1] ?? inner;\n\n          if (inner.includes(\"\\n\")) {\n            return;\n          }\n\n          // The beta named the function `Response` but was changed in stable.\n          // Keep this here for a while because there's no easy way to migrate\n          if (name === \"Response\") {\n            name = \"response\";\n          }\n\n          let option = options.find(\n            (o) => o.name === name || (o.type === \"function\" && o.aliases?.includes(name)),\n          );\n\n          if (option == null) {\n            const from = node.from; // Cache here so the reference doesn't change\n            option = {\n              type: \"variable\",\n              invalid: true,\n              name: inner,\n              value: null,\n              label: inner,\n              onClick: () => {\n                onClickMissingVariable(name, rawTag, from);\n              },\n            };\n          }\n\n          if (option.type === \"function\") {\n            const tokens = parseTemplate(rawTag);\n            const rawValues = collectArgumentValues(tokens, option);\n            const values = applyFormInputDefaults(option.args, rawValues);\n            const label = makeFunctionLabel(option, values);\n            const validationErr = validateTemplateFunctionArgs(option.name, option.args, values);\n            option = { ...option, label, invalid: !!validationErr }; // Clone so we don't mutate the original\n          }\n\n          const widget = new TemplateTagWidget(option, rawTag, node.from);\n          const deco = Decoration.replace({ widget, inclusive: true });\n          widgets.push(deco.range(node.from, node.to));\n        }\n      },\n    });\n  }\n\n  // Widgets must be sorted start to end\n  widgets.sort((a, b) => a.from - b.from);\n\n  return Decoration.set(widgets);\n}\n\nexport function templateTagsPlugin(\n  options: TwigCompletionOption[],\n  onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void,\n) {\n  return ViewPlugin.fromClass(\n    class {\n      decorations: DecorationSet;\n\n      constructor(view: EditorView) {\n        this.decorations = templateTags(view, options, onClickMissingVariable);\n      }\n\n      update(update: ViewUpdate) {\n        this.decorations = templateTags(update.view, options, onClickMissingVariable);\n      }\n    },\n    {\n      decorations(v) {\n        return v.decorations;\n      },\n      provide(plugin) {\n        return EditorView.atomicRanges.of((view) => {\n          return view.plugin(plugin)?.decorations || Decoration.none;\n        });\n      },\n    },\n  );\n}\n\nfunction isSelectionInsideNode(view: EditorView, node: SyntaxNodeRef) {\n  for (const r of view.state.selection.ranges) {\n    if (r.from > node.from && r.to < node.to) return true;\n  }\n  return false;\n}\n\nfunction makeFunctionLabel(\n  fn: TemplateFunction,\n  values: { [p: string]: JsonPrimitive | undefined },\n): HTMLElement | string {\n  if (fn.args.length === 0) return fn.name;\n\n  const $outer = document.createElement(\"span\");\n  $outer.className = \"fn\";\n  const $bOpen = document.createElement(\"span\");\n  $bOpen.className = \"fn-bracket\";\n  $bOpen.textContent = \"(\";\n  $outer.appendChild(document.createTextNode(fn.name));\n  $outer.appendChild($bOpen);\n\n  const $inner = document.createElement(\"span\");\n  $inner.className = \"fn-inner\";\n  $inner.title = \"\";\n  fn.previewArgs?.forEach((name: string, i: number, all: string[]) => {\n    const v = String(values[name] || \"\");\n    if (!v) return;\n    if (all.length > 1) {\n      const $c = document.createElement(\"span\");\n      $c.className = \"fn-arg-name\";\n      $c.textContent = i > 0 ? `, ${name}=` : `${name}=`;\n      $inner.appendChild($c);\n    }\n\n    const $v = document.createElement(\"span\");\n    $v.className = \"fn-arg-value\";\n    $v.textContent = v.includes(\" \") ? `'${v}'` : v;\n    $inner.appendChild($v);\n  });\n  fn.args.forEach((a: FormInput, i: number) => {\n    if (!(\"name\" in a)) return;\n    const v = values[a.name];\n    if (v == null) return;\n    if (i > 0) $inner.title += \"\\n\";\n    $inner.title += `${a.name} = ${JSON.stringify(v)}`;\n  });\n\n  if ($inner.childNodes.length === 0) {\n    $inner.appendChild(document.createTextNode(\"…\"));\n  }\n\n  $outer.appendChild($inner);\n\n  const $bClose = document.createElement(\"span\");\n  $bClose.className = \"fn-bracket\";\n  $bClose.textContent = \")\";\n  $outer.appendChild($bClose);\n\n  return $outer;\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/twig/twig.grammar",
    "content": "@top Template { (Tag | Text)* }\n\n@local tokens {\n  TagClose { \"]}\" }\n  @else TagContent\n}\n\n@skip { } {\n  TagOpen { \"${[\" }\n  Tag { TagOpen (TagContent)+ TagClose }\n}\n\n@tokens {\n  Text { ![$] Text? | \"$\" (@eof | ![{] Text? | \"{\" ![[] Text?) }\n}\n\n@external propSource highlight from \"./highlight\"\n"
  },
  {
    "path": "src-web/components/core/Editor/twig/twig.terms.ts",
    "content": "// This file was generated by lezer-generator. You probably shouldn't edit it.\nexport const Template = 1,\n  Tag = 2,\n  TagOpen = 3,\n  TagContent = 4,\n  TagClose = 5,\n  Text = 6;\n"
  },
  {
    "path": "src-web/components/core/Editor/twig/twig.test.ts",
    "content": "/* oxlint-disable no-template-curly-in-string */\n\nimport { describe, expect, test } from \"vite-plus/test\";\nimport { parser } from \"./twig\";\n\nfunction getNodeNames(input: string): string[] {\n  const tree = parser.parse(input);\n  const nodes: string[] = [];\n  const cursor = tree.cursor();\n  do {\n    if (cursor.name !== \"Template\") {\n      nodes.push(cursor.name);\n    }\n  } while (cursor.next());\n  return nodes;\n}\n\nfunction hasTag(input: string): boolean {\n  return getNodeNames(input).includes(\"Tag\");\n}\n\nfunction hasError(input: string): boolean {\n  return getNodeNames(input).includes(\"⚠\");\n}\n\ndescribe(\"twig grammar\", () => {\n  describe(\"${[var]} format (valid template tags)\", () => {\n    test(\"parses simple variable as Tag\", () => {\n      expect(hasTag(\"${[var]}\")).toBe(true);\n      expect(hasError(\"${[var]}\")).toBe(false);\n    });\n\n    test(\"parses variable with whitespace as Tag\", () => {\n      expect(hasTag(\"${[ var ]}\")).toBe(true);\n      expect(hasError(\"${[ var ]}\")).toBe(false);\n    });\n\n    test(\"parses embedded variable as Tag\", () => {\n      expect(hasTag(\"hello ${[name]} world\")).toBe(true);\n      expect(hasError(\"hello ${[name]} world\")).toBe(false);\n    });\n\n    test(\"parses function call as Tag\", () => {\n      expect(hasTag(\"${[fn()]}\")).toBe(true);\n      expect(hasError(\"${[fn()]}\")).toBe(false);\n    });\n  });\n\n  describe(\"${var} format (should be plain text, not tags)\", () => {\n    test(\"parses ${var} as plain Text without errors\", () => {\n      expect(hasTag(\"${var}\")).toBe(false);\n      expect(hasError(\"${var}\")).toBe(false);\n    });\n\n    test(\"parses embedded ${var} as plain Text\", () => {\n      expect(hasTag(\"hello ${name} world\")).toBe(false);\n      expect(hasError(\"hello ${name} world\")).toBe(false);\n    });\n\n    test(\"parses JSON with ${var} as plain Text\", () => {\n      const json = '{\"key\": \"${value}\"}';\n      expect(hasTag(json)).toBe(false);\n      expect(hasError(json)).toBe(false);\n    });\n\n    test(\"parses multiple ${var} as plain Text\", () => {\n      expect(hasTag(\"${a} and ${b}\")).toBe(false);\n      expect(hasError(\"${a} and ${b}\")).toBe(false);\n    });\n  });\n\n  describe(\"mixed content\", () => {\n    test(\"distinguishes ${var} from ${[var]} in same string\", () => {\n      const input = \"${plain} and ${[tag]}\";\n      expect(hasTag(input)).toBe(true);\n      expect(hasError(input)).toBe(false);\n    });\n\n    test(\"parses JSON with ${[var]} as having Tag\", () => {\n      const json = '{\"key\": \"${[value]}\"}';\n      expect(hasTag(json)).toBe(true);\n      expect(hasError(json)).toBe(false);\n    });\n  });\n\n  describe(\"edge cases\", () => {\n    test(\"handles $ at end of string\", () => {\n      expect(hasError(\"hello$\")).toBe(false);\n      expect(hasTag(\"hello$\")).toBe(false);\n    });\n\n    test(\"handles ${ at end of string without crash\", () => {\n      // Incomplete syntax may produce errors, but should not crash\n      expect(() => parser.parse(\"hello${\")).not.toThrow();\n    });\n\n    test(\"handles ${[ without closing without crash\", () => {\n      // Unclosed tag may produce partial match, but should not crash\n      expect(() => parser.parse(\"${[unclosed\")).not.toThrow();\n    });\n\n    test(\"handles empty ${[]}\", () => {\n      // Empty tags may or may not be valid depending on grammar\n      // Just ensure no crash\n      expect(() => parser.parse(\"${[]}\")).not.toThrow();\n    });\n  });\n});\n"
  },
  {
    "path": "src-web/components/core/Editor/twig/twig.ts",
    "content": "// This file was generated by lezer-generator. You probably shouldn't edit it.\nimport { LocalTokenGroup, LRParser } from \"@lezer/lr\";\nimport { highlight } from \"./highlight\";\nexport const parser = LRParser.deserialize({\n  version: 14,\n  states:\n    \"!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d\",\n  stateData: \"g~OUROYPO~OSTO~OSTOTXO~O\",\n  goto: \"nXPPY^PPPbhTROSTQOSQSORVSQUQRWU\",\n  nodeNames: \"⚠ Template Tag TagOpen TagContent TagClose Text\",\n  maxTerm: 10,\n  propSources: [highlight],\n  skippedNodes: [0],\n  repeatNodeCount: 2,\n  tokenData:\n    \"#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTU~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOU~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OY~\",\n  tokenizers: [1, new LocalTokenGroup(\"b~RP#P#QU~XP#q#r[~aOT~~\", 17, 4)],\n  topRules: { Template: [0, 1] },\n  tokenPrec: 0,\n});\n"
  },
  {
    "path": "src-web/components/core/Editor/twig/util.ts",
    "content": "import type { FormInput, TemplateFunction } from \"@yaakapp-internal/plugins\";\nimport type { Tokens } from \"@yaakapp-internal/templates\";\n\n/**\n * Process the initial tokens from the template and merge those with the default values pulled from\n * the template function definition.\n */\nexport function collectArgumentValues(initialTokens: Tokens, templateFunction: TemplateFunction) {\n  const initial: Record<string, string | boolean> = {};\n  const initialArgs =\n    initialTokens.tokens[0]?.type === \"tag\" && initialTokens.tokens[0]?.val.type === \"fn\"\n      ? initialTokens.tokens[0]?.val.args\n      : [];\n\n  const processArg = (arg: FormInput) => {\n    if (\"inputs\" in arg && arg.inputs) {\n      arg.inputs.forEach(processArg);\n    }\n    if (!(\"name\" in arg)) return;\n\n    const initialArg = initialArgs.find((a) => a.name === arg.name);\n    const initialArgValue =\n      initialArg?.value.type === \"str\"\n        ? initialArg?.value.text\n        : initialArg?.value.type === \"bool\"\n          ? initialArg.value.value\n          : undefined;\n    const value = initialArgValue ?? arg.defaultValue;\n    if (value != null) {\n      initial[arg.name] = value;\n    }\n  };\n\n  templateFunction.args.forEach(processArg);\n\n  return initial;\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/url/completion.ts",
    "content": "import { genericCompletion } from \"../genericCompletion\";\n\nexport const completions = genericCompletion({\n  options: [\n    { label: \"http://\", type: \"constant\" },\n    { label: \"https://\", type: \"constant\" },\n  ],\n  minMatch: 1,\n});\n"
  },
  {
    "path": "src-web/components/core/Editor/url/extension.ts",
    "content": "import { LanguageSupport, LRLanguage } from \"@codemirror/language\";\nimport { parser } from \"./url\";\n\nconst urlLanguage = LRLanguage.define({\n  name: \"url\",\n  parser,\n  languageData: {},\n});\n\nexport function url() {\n  return new LanguageSupport(urlLanguage, []);\n}\n"
  },
  {
    "path": "src-web/components/core/Editor/url/highlight.ts",
    "content": "import { styleTags, tags as t } from \"@lezer/highlight\";\n\nexport const highlight = styleTags({\n  Protocol: t.comment,\n  Placeholder: t.emphasis,\n  // PathSegment: t.tagName,\n  // Host: t.variableName,\n  // Path: t.bool,\n  // Query: t.string,\n});\n"
  },
  {
    "path": "src-web/components/core/Editor/url/url.grammar",
    "content": "@top url { Protocol? Host Path? Query? }\n\nPath { (\"/\" (Placeholder | PathSegment))+ }\n\nQuery { \"?\" queryPair (\"&\" queryPair)* }\n\n@tokens {\n    Protocol { $[a-zA-Z]+ \"://\" }\n    Host { $[a-zA-Z0-9-_.:\\[\\]]+ }\n    @precedence { Protocol, Host }\n\n    Placeholder { \":\" ![/?#]+ }\n    PathSegment { ![?#/]+ }\n    @precedence { Placeholder, PathSegment }\n\n    queryPair { ($[a-zA-Z0-9]+ (\"=\" $[a-zA-Z0-9]*)?) }\n}\n\n@external propSource highlight from \"./highlight\"\n"
  },
  {
    "path": "src-web/components/core/Editor/url/url.terms.ts",
    "content": "// This file was generated by lezer-generator. You probably shouldn't edit it.\nexport const url = 1,\n  Protocol = 2,\n  Host = 3,\n  Port = 4,\n  Path = 5,\n  Placeholder = 6,\n  PathSegment = 7,\n  Query = 8;\n"
  },
  {
    "path": "src-web/components/core/Editor/url/url.ts",
    "content": "// This file was generated by lezer-generator. You probably shouldn't edit it.\nimport { LRParser } from \"@lezer/lr\";\nimport { highlight } from \"./highlight\";\nexport const parser = LRParser.deserialize({\n  version: 14,\n  states:\n    \"!|OQOPOOQYOPOOOTOPOOObOQO'#CdOjOPO'#C`OuOSO'#CcQOOOOOQ]OPOOOOOO,59O,59OOOOO-E6b-E6bOzOPO,58}O!SOSO'#CeO!XOPO1G.iOOOO,59P,59POOOO-E6c-E6c\",\n  stateData: \"!g~OQQORPO~OZRO[TO~OTWOUWO~OZROYSX[SX~O]YO~O^ZOYVa~O]]O~O^ZOYVi~OQRTUT~\",\n  goto: \"nYPPPPZPP^bhRVPTUPVQSPRXSQ[YR^[\",\n  nodeNames: \"⚠ url Protocol Host Path Placeholder PathSegment Query\",\n  maxTerm: 14,\n  propSources: [highlight],\n  skippedNodes: [0],\n  repeatNodeCount: 2,\n  tokenData:\n    \".i~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+W!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)RQ)YUTQUQOs)Rt!P)R!Q!a)R!b;'S)R;'S;=`)l<%lO)RQ)oP;=`<%l)RR){cRPTQUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)R~+]O[~V+fe]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],w!]!_!j!_!`&u!`!a!j!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jR-OdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.^!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.aP!P!Q.dP.iOQP\",\n  tokenizers: [0, 1, 2],\n  topRules: { url: [0, 1] },\n  tokenPrec: 63,\n});\n"
  },
  {
    "path": "src-web/components/core/EventViewer.tsx",
    "content": "import type { Virtualizer } from \"@tanstack/react-virtual\";\nimport { format } from \"date-fns\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useMemo, useRef, useState } from \"react\";\nimport { useEventViewerKeyboard } from \"../../hooks/useEventViewerKeyboard\";\nimport { CopyIconButton } from \"../CopyIconButton\";\nimport { AutoScroller } from \"./AutoScroller\";\nimport { Banner } from \"./Banner\";\nimport { Button } from \"./Button\";\nimport { Separator } from \"./Separator\";\nimport { SplitLayout } from \"./SplitLayout\";\nimport { HStack } from \"./Stacks\";\nimport { IconButton } from \"./IconButton\";\nimport classNames from \"classnames\";\n\ninterface EventViewerProps<T> {\n  /** Array of events to display */\n  events: T[];\n\n  /** Get unique key for each event */\n  getEventKey: (event: T, index: number) => string;\n\n  /** Render the event row - receives event, index, isActive, and onClick */\n  renderRow: (props: {\n    event: T;\n    index: number;\n    isActive: boolean;\n    onClick: () => void;\n  }) => ReactNode;\n\n  /** Render the detail pane for the selected event */\n  renderDetail?: (props: { event: T; index: number; onClose: () => void }) => ReactNode;\n\n  /** Optional header above the event list (e.g., connection status) */\n  header?: ReactNode;\n\n  /** Error message to display as a banner */\n  error?: string | null;\n\n  /** Name for SplitLayout state persistence */\n  splitLayoutName: string;\n\n  /** Default ratio for the split (0.0 - 1.0) */\n  defaultRatio?: number;\n\n  /** Enable keyboard navigation (arrow keys) */\n  enableKeyboardNav?: boolean;\n\n  /** Loading state */\n  isLoading?: boolean;\n\n  /** Message to show while loading */\n  loadingMessage?: string;\n\n  /** Message to show when no events */\n  emptyMessage?: string;\n\n  /** Callback when active index changes (for controlled state in parent) */\n  onActiveIndexChange?: (index: number | null) => void;\n}\n\nexport function EventViewer<T>({\n  events,\n  getEventKey,\n  renderRow,\n  renderDetail,\n  header,\n  error,\n  splitLayoutName,\n  defaultRatio = 0.4,\n  enableKeyboardNav = true,\n  isLoading = false,\n  loadingMessage = \"Loading events...\",\n  emptyMessage = \"No events recorded\",\n  onActiveIndexChange,\n}: EventViewerProps<T>) {\n  const [activeIndex, setActiveIndexInternal] = useState<number | null>(null);\n  const [isPanelOpen, setIsPanelOpen] = useState(false);\n\n  // Wrap setActiveIndex to notify parent\n  const setActiveIndex = useCallback(\n    (indexOrUpdater: number | null | ((prev: number | null) => number | null)) => {\n      setActiveIndexInternal((prev) => {\n        const newIndex =\n          typeof indexOrUpdater === \"function\" ? indexOrUpdater(prev) : indexOrUpdater;\n        onActiveIndexChange?.(newIndex);\n        return newIndex;\n      });\n    },\n    [onActiveIndexChange],\n  );\n  const containerRef = useRef<HTMLDivElement>(null);\n  const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element> | null>(null);\n\n  const activeEvent = useMemo(\n    () => (activeIndex != null ? events[activeIndex] : null),\n    [activeIndex, events],\n  );\n\n  // Check if the event list container is focused\n  const isContainerFocused = useCallback(() => {\n    return containerRef.current?.contains(document.activeElement) ?? false;\n  }, []);\n\n  // Keyboard navigation\n  useEventViewerKeyboard({\n    totalCount: events.length,\n    activeIndex,\n    setActiveIndex,\n    virtualizer: virtualizerRef.current,\n    isContainerFocused,\n    enabled: enableKeyboardNav,\n    closePanel: () => setIsPanelOpen(false),\n    openPanel: () => setIsPanelOpen(true),\n  });\n\n  // Handle virtualizer ready callback\n  const handleVirtualizerReady = useCallback(\n    (virtualizer: Virtualizer<HTMLDivElement, Element>) => {\n      virtualizerRef.current = virtualizer;\n    },\n    [],\n  );\n\n  // Handle row click - select and open panel, scroll into view\n  const handleRowClick = useCallback(\n    (index: number) => {\n      setActiveIndex(index);\n      setIsPanelOpen(true);\n      // Scroll to ensure selected item is visible after panel opens\n      requestAnimationFrame(() => {\n        virtualizerRef.current?.scrollToIndex(index, { align: \"auto\" });\n      });\n    },\n    [setActiveIndex],\n  );\n\n  const handleClose = useCallback(() => {\n    setIsPanelOpen(false);\n  }, []);\n\n  if (isLoading) {\n    return <div className=\"p-3 text-text-subtlest italic\">{loadingMessage}</div>;\n  }\n\n  if (events.length === 0 && !error) {\n    return <div className=\"p-3 text-text-subtlest italic\">{emptyMessage}</div>;\n  }\n\n  return (\n    <div ref={containerRef} className=\"h-full\">\n      <SplitLayout\n        layout=\"vertical\"\n        name={splitLayoutName}\n        defaultRatio={defaultRatio}\n        minHeightPx={10}\n        firstSlot={({ style }) => (\n          <div style={style} className=\"w-full h-full grid grid-rows-[auto_minmax(0,1fr)]\">\n            {header ?? <span aria-hidden />}\n            <AutoScroller\n              data={events}\n              focusable={enableKeyboardNav}\n              onVirtualizerReady={handleVirtualizerReady}\n              header={\n                error && (\n                  <Banner color=\"danger\" className=\"m-3\">\n                    {error}\n                  </Banner>\n                )\n              }\n              render={(event, index) => (\n                <div key={getEventKey(event, index)}>\n                  {renderRow({\n                    event,\n                    index,\n                    isActive: index === activeIndex,\n                    onClick: () => handleRowClick(index),\n                  })}\n                </div>\n              )}\n            />\n          </div>\n        )}\n        secondSlot={\n          activeEvent != null && renderDetail && isPanelOpen\n            ? ({ style }) => (\n                <div style={style} className=\"grid grid-rows-[auto_minmax(0,1fr)] bg-surface\">\n                  <div className=\"pb-3 px-2\">\n                    <Separator />\n                  </div>\n                  <div className=\"mx-2 overflow-y-auto\">\n                    {renderDetail({\n                      event: activeEvent,\n                      index: activeIndex ?? 0,\n                      onClose: handleClose,\n                    })}\n                  </div>\n                </div>\n              )\n            : null\n        }\n      />\n    </div>\n  );\n}\n\nexport interface EventDetailAction {\n  /** Unique key for React */\n  key: string;\n  /** Button label */\n  label: string;\n  /** Optional icon */\n  icon?: ReactNode;\n  /** Click handler */\n  onClick: () => void;\n}\n\ninterface EventDetailHeaderProps {\n  title: string;\n  prefix?: ReactNode;\n  timestamp?: string;\n  actions?: EventDetailAction[];\n  copyText?: string;\n  onClose?: () => void;\n}\n\nexport function EventDetailHeader({\n  title,\n  prefix,\n  timestamp,\n  actions,\n  copyText,\n  onClose,\n}: EventDetailHeaderProps) {\n  const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), \"HH:mm:ss.SSS\") : null;\n\n  return (\n    <div className=\"flex items-center justify-between gap-2 mb-2 h-xs\">\n      <HStack space={2} className=\"items-center min-w-0\">\n        {prefix}\n        <h3 className=\"font-semibold select-auto cursor-auto truncate\">{title}</h3>\n      </HStack>\n      <HStack space={2} className=\"items-center\">\n        {actions?.map((action) => (\n          <Button key={action.key} variant=\"border\" size=\"xs\" onClick={action.onClick}>\n            {action.icon}\n            {action.label}\n          </Button>\n        ))}\n        {copyText != null && (\n          <CopyIconButton text={copyText} size=\"xs\" title=\"Copy\" variant=\"border\" iconSize=\"sm\" />\n        )}\n        {formattedTime && (\n          <span className=\"text-text-subtlest font-mono text-editor ml-2\">{formattedTime}</span>\n        )}\n        <div\n          className={classNames(\n            copyText != null ||\n              formattedTime ||\n              ((actions ?? []).length > 0 && \"border-l border-l-surface-highlight ml-2 pl-3\"),\n          )}\n        >\n          <IconButton\n            color=\"custom\"\n            className=\"text-text-subtle -mr-3\"\n            size=\"xs\"\n            icon=\"x\"\n            title=\"Close event panel\"\n            onClick={onClose}\n          />\n        </div>\n      </HStack>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/EventViewerRow.tsx",
    "content": "import classNames from \"classnames\";\nimport { format } from \"date-fns\";\nimport type { ReactNode } from \"react\";\n\ninterface EventViewerRowProps {\n  isActive: boolean;\n  onClick: () => void;\n  icon: ReactNode;\n  content: ReactNode;\n  timestamp?: string;\n}\n\nexport function EventViewerRow({\n  isActive,\n  onClick,\n  icon,\n  content,\n  timestamp,\n}: EventViewerRowProps) {\n  return (\n    <div className=\"px-1\">\n      <button\n        type=\"button\"\n        onClick={onClick}\n        className={classNames(\n          \"w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left\",\n          \"px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-none focus:text-text rounded\",\n          isActive && \"bg-surface-active !text-text\",\n          \"text-text-subtle hover:text\",\n        )}\n      >\n        {icon}\n        <div className=\"w-full truncate\">{content}</div>\n        {timestamp && <div className=\"opacity-50\">{format(`${timestamp}Z`, \"HH:mm:ss.SSS\")}</div>}\n      </button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/FormattedError.tsx",
    "content": "import classNames from \"classnames\";\nimport type { ReactNode } from \"react\";\n\ninterface Props {\n  children: ReactNode;\n  className?: string;\n}\n\nexport function FormattedError({ children, className }: Props) {\n  return (\n    <pre\n      className={classNames(\n        className,\n        \"cursor-text select-auto\",\n        \"[&_*]:cursor-text [&_*]:select-auto\",\n        \"font-mono text-sm w-full bg-surface-highlight p-3 rounded\",\n        \"whitespace-pre-wrap border border-danger border-dashed overflow-x-auto\",\n      )}\n    >\n      {children}\n    </pre>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Heading.tsx",
    "content": "import classNames from \"classnames\";\nimport type { HTMLAttributes } from \"react\";\n\ninterface Props extends HTMLAttributes<HTMLHeadingElement> {\n  level?: 1 | 2 | 3;\n}\n\nexport function Heading({ className, level = 1, ...props }: Props) {\n  const Component = level === 1 ? \"h1\" : level === 2 ? \"h2\" : \"h3\";\n  return (\n    <Component\n      className={classNames(\n        className,\n        \"font-semibold text-text\",\n        level === 1 && \"text-2xl\",\n        level === 2 && \"text-xl\",\n        level === 3 && \"text-lg\",\n      )}\n      {...props}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Hotkey.tsx",
    "content": "import classNames from \"classnames\";\nimport type { HotkeyAction } from \"../../hooks/useHotKey\";\nimport { useFormattedHotkey } from \"../../hooks/useHotKey\";\nimport { HStack } from \"./Stacks\";\n\ninterface Props {\n  action: HotkeyAction | null;\n  className?: string;\n  variant?: \"text\" | \"with-bg\";\n}\n\nexport function Hotkey({ action, className, variant }: Props) {\n  const labelParts = useFormattedHotkey(action);\n  if (labelParts === null) {\n    return null;\n  }\n\n  return <HotkeyRaw labelParts={labelParts} className={className} variant={variant} />;\n}\n\ninterface HotkeyRawProps {\n  labelParts: string[];\n  className?: string;\n  variant?: \"text\" | \"with-bg\";\n}\n\nexport function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {\n  return (\n    <HStack\n      className={classNames(\n        className,\n        variant === \"with-bg\" &&\n          \"rounded bg-surface-highlight px-1 border border-border text-text-subtle\",\n        variant === \"text\" && \"text-text-subtlest\",\n      )}\n    >\n      {labelParts.map((char, index) => (\n        // oxlint-disable-next-line react/no-array-index-key\n        <div key={index} className=\"min-w-[1em] text-center\">\n          {char}\n        </div>\n      ))}\n    </HStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/HotkeyLabel.tsx",
    "content": "import classNames from \"classnames\";\nimport type { HotkeyAction } from \"../../hooks/useHotKey\";\nimport { useHotkeyLabel } from \"../../hooks/useHotKey\";\n\ninterface Props {\n  action: HotkeyAction;\n  className?: string;\n}\n\nexport function HotkeyLabel({ action, className }: Props) {\n  const label = useHotkeyLabel(action);\n  return (\n    <span className={classNames(className, \"text-text-subtle whitespace-nowrap\")}>{label}</span>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/HotkeyList.tsx",
    "content": "import classNames from \"classnames\";\nimport type { ReactNode } from \"react\";\nimport { Fragment } from \"react\";\nimport type { HotkeyAction } from \"../../hooks/useHotKey\";\nimport { Hotkey } from \"./Hotkey\";\nimport { HotkeyLabel } from \"./HotkeyLabel\";\n\ninterface Props {\n  hotkeys: HotkeyAction[];\n  bottomSlot?: ReactNode;\n  className?: string;\n}\n\nexport const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {\n  return (\n    <div className={classNames(className, \"h-full flex items-center justify-center\")}>\n      <div className=\"grid gap-2 grid-cols-[auto_auto]\">\n        {hotkeys.map((hotkey) => (\n          <Fragment key={hotkey}>\n            <HotkeyLabel className=\"truncate\" action={hotkey} />\n            <Hotkey className=\"ml-4\" action={hotkey} />\n          </Fragment>\n        ))}\n        {bottomSlot}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src-web/components/core/HttpMethodTag.tsx",
    "content": "import type { GrpcRequest, HttpRequest, WebsocketRequest } from \"@yaakapp-internal/models\";\nimport { settingsAtom } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport { memo } from \"react\";\n\ninterface Props {\n  request: HttpRequest | GrpcRequest | WebsocketRequest;\n  className?: string;\n  short?: boolean;\n  noAlias?: boolean;\n}\n\nconst methodNames: Record<string, string> = {\n  get: \"GET\",\n  put: \"PUT\",\n  post: \"POST\",\n  patch: \"PTCH\",\n  delete: \"DELE\",\n  options: \"OPTN\",\n  head: \"HEAD\",\n  query: \"QURY\",\n  graphql: \"GQL\",\n  grpc: \"GRPC\",\n  websocket: \"WS\",\n};\n\nexport const HttpMethodTag = memo(function HttpMethodTag({\n  request,\n  className,\n  short,\n  noAlias,\n}: Props) {\n  const method =\n    request.model === \"http_request\" && request.bodyType === \"graphql\" && !noAlias\n      ? \"graphql\"\n      : request.model === \"grpc_request\"\n        ? \"grpc\"\n        : request.model === \"websocket_request\"\n          ? \"websocket\"\n          : request.method;\n\n  return <HttpMethodTagRaw method={method} className={className} short={short} />;\n});\n\nexport function HttpMethodTagRaw({\n  className,\n  method,\n  short,\n  forceColor,\n}: {\n  method: string;\n  className?: string;\n  short?: boolean;\n  forceColor?: boolean;\n}) {\n  let label = method.toUpperCase();\n  if (short) {\n    label = methodNames[method.toLowerCase()] ?? method.slice(0, 4);\n    label = label.padStart(4, \" \");\n  }\n\n  const m = method.toUpperCase();\n\n  const settings = useAtomValue(settingsAtom);\n  const colored = forceColor || settings.coloredMethods;\n\n  return (\n    <span\n      className={classNames(\n        className,\n        !colored && \"text-text-subtle\",\n        colored && m === \"GRAPHQL\" && \"text-info\",\n        colored && m === \"WEBSOCKET\" && \"text-info\",\n        colored && m === \"GRPC\" && \"text-info\",\n        colored && m === \"QUERY\" && \"text-text-subtle\",\n        colored && m === \"OPTIONS\" && \"text-info\",\n        colored && m === \"HEAD\" && \"text-text-subtle\",\n        colored && m === \"GET\" && \"text-primary\",\n        colored && m === \"PUT\" && \"text-warning\",\n        colored && m === \"PATCH\" && \"text-notice\",\n        colored && m === \"POST\" && \"text-success\",\n        colored && m === \"DELETE\" && \"text-danger\",\n        \"font-mono flex-shrink-0 whitespace-pre\",\n        \"pt-[0.15em]\", // Fix for monospace font not vertically centering\n      )}\n    >\n      {label}\n    </span>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/HttpResponseDurationTag.tsx",
    "content": "import type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { useEffect, useRef, useState } from \"react\";\n\ninterface Props {\n  response: HttpResponse;\n}\n\nexport function HttpResponseDurationTag({ response }: Props) {\n  const [fallbackElapsed, setFallbackElapsed] = useState<number>(0);\n  const timeout = useRef<NodeJS.Timeout>(undefined);\n\n  // Calculate the duration of the response for use when the response hasn't finished yet\n  useEffect(() => {\n    clearInterval(timeout.current);\n    if (response.state === \"closed\") return;\n    timeout.current = setInterval(() => {\n      setFallbackElapsed(Date.now() - new Date(`${response.createdAt}Z`).getTime());\n    }, 100);\n    return () => clearInterval(timeout.current);\n  }, [response.createdAt, response.state]);\n\n  const dnsValue = response.elapsedDns > 0 ? formatMillis(response.elapsedDns) : \"--\";\n  const title = `DNS: ${dnsValue}\\nHEADER: ${formatMillis(response.elapsedHeaders)}\\nTOTAL: ${formatMillis(response.elapsed)}`;\n\n  const elapsed = response.state === \"closed\" ? response.elapsed : fallbackElapsed;\n\n  return (\n    <span className=\"font-mono\" title={title}>\n      {formatMillis(elapsed)}\n    </span>\n  );\n}\n\nfunction formatMillis(ms: number) {\n  if (ms < 1000) {\n    return `${ms} ms`;\n  }\n  if (ms < 60_000) {\n    const seconds = (ms / 1000).toFixed(ms < 10_000 ? 1 : 0);\n    return `${seconds} s`;\n  }\n  const minutes = Math.floor(ms / 60_000);\n  const seconds = Math.round((ms % 60_000) / 1000);\n  return `${minutes}m ${seconds}s`;\n}\n"
  },
  {
    "path": "src-web/components/core/HttpStatusTag.tsx",
    "content": "import type { HttpResponse, HttpResponseState } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\n\ninterface Props {\n  response: HttpResponse;\n  className?: string;\n  showReason?: boolean;\n  short?: boolean;\n}\n\nexport function HttpStatusTag({ response, ...props }: Props) {\n  const { status, state, statusReason } = response;\n  return <HttpStatusTagRaw status={status} state={state} statusReason={statusReason} {...props} />;\n}\n\nexport function HttpStatusTagRaw({\n  status,\n  state,\n  className,\n  showReason,\n  statusReason,\n  short,\n}: Omit<Props, \"response\"> & {\n  status: number | string;\n  state?: HttpResponseState;\n  statusReason?: string | null;\n}) {\n  let colorClass: string;\n  let label = `${status}`;\n  const statusN = typeof status === \"number\" ? status : parseInt(status, 10);\n\n  if (state === \"initialized\") {\n    label = short ? \"CONN\" : \"CONNECTING\";\n    colorClass = \"text-text-subtle\";\n  } else if (statusN < 100) {\n    label = short ? \"ERR\" : \"ERROR\";\n    colorClass = \"text-danger\";\n  } else if (statusN < 200) {\n    colorClass = \"text-info\";\n  } else if (statusN < 300) {\n    colorClass = \"text-success\";\n  } else if (statusN < 400) {\n    colorClass = \"text-primary\";\n  } else if (statusN < 500) {\n    colorClass = \"text-warning\";\n  } else {\n    colorClass = \"text-danger\";\n  }\n\n  return (\n    <span className={classNames(className, \"font-mono min-w-0\", colorClass)}>\n      {label} {showReason && statusReason}\n    </span>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Icon.tsx",
    "content": "import type { Color } from \"@yaakapp-internal/plugins\";\nimport classNames from \"classnames\";\nimport {\n  AlarmClockIcon,\n  AlertTriangleIcon,\n  ArchiveIcon,\n  ArrowBigDownDashIcon,\n  ArrowBigLeftDashIcon,\n  ArrowBigRightDashIcon,\n  ArrowBigRightIcon,\n  ArrowBigUpDashIcon,\n  ArrowDownIcon,\n  ArrowDownToDotIcon,\n  ArrowDownToLineIcon,\n  ArrowLeftIcon,\n  ArrowRightCircleIcon,\n  ArrowRightIcon,\n  ArrowUpDownIcon,\n  ArrowUpFromDotIcon,\n  ArrowUpFromLineIcon,\n  ArrowUpIcon,\n  BadgeCheckIcon,\n  BookOpenText,\n  BoxIcon,\n  CakeIcon,\n  CheckCircleIcon,\n  CheckIcon,\n  ChevronDownIcon,\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  ChevronsDownUpIcon,\n  ChevronsUpDownIcon,\n  CircleAlertIcon,\n  CircleDashedIcon,\n  CircleDollarSignIcon,\n  CircleFadingArrowUpIcon,\n  CircleHelpIcon,\n  CircleOffIcon,\n  ClipboardPasteIcon,\n  ClockIcon,\n  CodeIcon,\n  Columns2Icon,\n  CommandIcon,\n  CookieIcon,\n  CopyCheck,\n  CopyIcon,\n  CornerRightDownIcon,\n  CornerRightUpIcon,\n  CreditCardIcon,\n  CrosshairIcon,\n  DotIcon,\n  DownloadIcon,\n  EllipsisIcon,\n  EllipsisVerticalIcon,\n  ExpandIcon,\n  ExternalLinkIcon,\n  EyeIcon,\n  EyeOffIcon,\n  FileCodeIcon,\n  FileIcon,\n  FileTextIcon,\n  FilterIcon,\n  FlameIcon,\n  FlaskConicalIcon,\n  FolderCodeIcon,\n  FolderCogIcon,\n  FolderDownIcon,\n  FolderGitIcon,\n  FolderIcon,\n  FolderInputIcon,\n  FolderOpenIcon,\n  FolderOutputIcon,\n  FolderSymlinkIcon,\n  FolderSyncIcon,\n  FolderUpIcon,\n  GiftIcon,\n  GitBranchIcon,\n  GitBranchPlusIcon,\n  GitCommitIcon,\n  GitCommitVerticalIcon,\n  GitForkIcon,\n  GitPullRequestIcon,\n  GlobeIcon,\n  GripVerticalIcon,\n  HandIcon,\n  HardDriveDownloadIcon,\n  HistoryIcon,\n  HomeIcon,\n  ImportIcon,\n  InfoIcon,\n  KeyboardIcon,\n  KeyRoundIcon,\n  LockIcon,\n  LockOpenIcon,\n  MergeIcon,\n  MessageSquare,\n  MinusCircleIcon,\n  MinusIcon,\n  MoonIcon,\n  MoreVerticalIcon,\n  PaletteIcon,\n  PanelLeftCloseIcon,\n  PanelLeftOpenIcon,\n  PencilIcon,\n  PinIcon,\n  PinOffIcon,\n  Plug,\n  PlusCircleIcon,\n  PlusIcon,\n  PuzzleIcon,\n  RefreshCcwIcon,\n  RefreshCwIcon,\n  RocketIcon,\n  RotateCcwIcon,\n  Rows2Icon,\n  SaveIcon,\n  SearchIcon,\n  SendHorizontalIcon,\n  SettingsIcon,\n  ShieldAlertIcon,\n  ShieldCheckIcon,\n  ShieldIcon,\n  ShieldOffIcon,\n  SparklesIcon,\n  SquareCheckIcon,\n  SquareIcon,\n  SquareTerminalIcon,\n  SunIcon,\n  TableIcon,\n  Trash2Icon,\n  UploadIcon,\n  VariableIcon,\n  Wand2Icon,\n  WifiIcon,\n  WrenchIcon,\n  XIcon,\n} from \"lucide-react\";\nimport type { CSSProperties, HTMLAttributes } from \"react\";\nimport { memo } from \"react\";\n\nconst icons = {\n  alarm_clock: AlarmClockIcon,\n  alert_triangle: AlertTriangleIcon,\n  archive: ArchiveIcon,\n  arrow_big_down_dash: ArrowBigDownDashIcon,\n  arrow_big_left_dash: ArrowBigLeftDashIcon,\n  arrow_big_right: ArrowBigRightIcon,\n  arrow_big_right_dash: ArrowBigRightDashIcon,\n  arrow_big_up_dash: ArrowBigUpDashIcon,\n  arrow_down: ArrowDownIcon,\n  arrow_down_to_dot: ArrowDownToDotIcon,\n  arrow_down_to_line: ArrowDownToLineIcon,\n  arrow_left: ArrowLeftIcon,\n  arrow_right: ArrowRightIcon,\n  arrow_right_circle: ArrowRightCircleIcon,\n  arrow_up: ArrowUpIcon,\n  arrow_up_down: ArrowUpDownIcon,\n  arrow_up_from_dot: ArrowUpFromDotIcon,\n  arrow_up_from_line: ArrowUpFromLineIcon,\n  badge_check: BadgeCheckIcon,\n  book_open_text: BookOpenText,\n  box: BoxIcon,\n  cake: CakeIcon,\n  chat: MessageSquare,\n  check: CheckIcon,\n  check_circle: CheckCircleIcon,\n  check_square_checked: SquareCheckIcon,\n  check_square_unchecked: SquareIcon,\n  chevron_down: ChevronDownIcon,\n  chevron_left: ChevronLeftIcon,\n  chevrons_up_down: ChevronsUpDownIcon,\n  chevrons_down_up: ChevronsDownUpIcon,\n  chevron_right: ChevronRightIcon,\n  circle_alert: CircleAlertIcon,\n  circle_dashed: CircleDashedIcon,\n  circle_dollar_sign: CircleDollarSignIcon,\n  circle_fading_arrow_up: CircleFadingArrowUpIcon,\n  clock: ClockIcon,\n  code: CodeIcon,\n  columns_2: Columns2Icon,\n  command: CommandIcon,\n  cookie: CookieIcon,\n  copy: CopyIcon,\n  copy_check: CopyCheck,\n  corner_right_down: CornerRightDownIcon,\n  corner_right_up: CornerRightUpIcon,\n  credit_card: CreditCardIcon,\n  crosshair: CrosshairIcon,\n  dot: DotIcon,\n  download: DownloadIcon,\n  ellipsis: EllipsisIcon,\n  ellipsis_vertical: EllipsisVerticalIcon,\n  expand: ExpandIcon,\n  external_link: ExternalLinkIcon,\n  eye: EyeIcon,\n  eye_closed: EyeOffIcon,\n  file: FileIcon,\n  file_code: FileCodeIcon,\n  file_text: FileTextIcon,\n  filter: FilterIcon,\n  flame: FlameIcon,\n  flask: FlaskConicalIcon,\n  folder: FolderIcon,\n  folder_code: FolderCodeIcon,\n  folder_cog: FolderCogIcon,\n  folder_git: FolderGitIcon,\n  folder_input: FolderInputIcon,\n  folder_open: FolderOpenIcon,\n  folder_output: FolderOutputIcon,\n  folder_symlink: FolderSymlinkIcon,\n  folder_sync: FolderSyncIcon,\n  folder_down: FolderDownIcon,\n  folder_up: FolderUpIcon,\n  gift: GiftIcon,\n  git_branch: GitBranchIcon,\n  git_branch_plus: GitBranchPlusIcon,\n  git_commit: GitCommitIcon,\n  git_commit_vertical: GitCommitVerticalIcon,\n  git_fork: GitForkIcon,\n  git_pull_request: GitPullRequestIcon,\n  globe: GlobeIcon,\n  grip_vertical: GripVerticalIcon,\n  circle_off: CircleOffIcon,\n  hand: HandIcon,\n  hard_drive_download: HardDriveDownloadIcon,\n  help: CircleHelpIcon,\n  history: HistoryIcon,\n  house: HomeIcon,\n  import: ImportIcon,\n  info: InfoIcon,\n  key_round: KeyRoundIcon,\n  keyboard: KeyboardIcon,\n  left_panel_hidden: PanelLeftOpenIcon,\n  left_panel_visible: PanelLeftCloseIcon,\n  lock: LockIcon,\n  lock_open: LockOpenIcon,\n  magic_wand: Wand2Icon,\n  merge: MergeIcon,\n  minus: MinusIcon,\n  minus_circle: MinusCircleIcon,\n  moon: MoonIcon,\n  more_vertical: MoreVerticalIcon,\n  palette: PaletteIcon,\n  paste: ClipboardPasteIcon,\n  pencil: PencilIcon,\n  pin: PinIcon,\n  plug: Plug,\n  plus: PlusIcon,\n  plus_circle: PlusCircleIcon,\n  puzzle: PuzzleIcon,\n  refresh: RefreshCwIcon,\n  rocket: RocketIcon,\n  rotate_ccw: RotateCcwIcon,\n  rows_2: Rows2Icon,\n  save: SaveIcon,\n  search: SearchIcon,\n  send_horizontal: SendHorizontalIcon,\n  settings: SettingsIcon,\n  shield: ShieldIcon,\n  shield_check: ShieldCheckIcon,\n  shield_off: ShieldOffIcon,\n  sparkles: SparklesIcon,\n  square_terminal: SquareTerminalIcon,\n  sun: SunIcon,\n  table: TableIcon,\n  text: FileTextIcon,\n  trash: Trash2Icon,\n  unpin: PinOffIcon,\n  update: RefreshCcwIcon,\n  upload: UploadIcon,\n  variable: VariableIcon,\n  wifi: WifiIcon,\n  wrench: WrenchIcon,\n  x: XIcon,\n  _unknown: ShieldAlertIcon,\n\n  empty: (props: HTMLAttributes<HTMLSpanElement>) => <div {...props} />,\n};\n\nexport interface IconProps {\n  icon: keyof typeof icons;\n  className?: string;\n  style?: CSSProperties;\n  size?: \"2xs\" | \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";\n  spin?: boolean;\n  title?: string;\n  color?: Color | \"custom\" | \"default\";\n}\n\nexport const Icon = memo(function Icon({\n  icon,\n  color = \"default\",\n  spin,\n  size = \"md\",\n  style,\n  className,\n  title,\n}: IconProps) {\n  const Component = icons[icon] ?? icons._unknown;\n  return (\n    <Component\n      style={style}\n      title={title}\n      className={classNames(\n        className,\n        !spin && \"transform-gpu\",\n        spin && \"animate-spin\",\n        \"flex-shrink-0\",\n        size === \"xl\" && \"h-6 w-6\",\n        size === \"lg\" && \"h-5 w-5\",\n        size === \"md\" && \"h-4 w-4\",\n        size === \"sm\" && \"h-3.5 w-3.5\",\n        size === \"xs\" && \"h-3 w-3\",\n        size === \"2xs\" && \"h-2.5 w-2.5\",\n        color === \"default\" && \"inherit\",\n        color === \"danger\" && \"text-danger\",\n        color === \"warning\" && \"text-warning\",\n        color === \"notice\" && \"text-notice\",\n        color === \"info\" && \"text-info\",\n        color === \"success\" && \"text-success\",\n        color === \"primary\" && \"text-primary\",\n        color === \"secondary\" && \"text-secondary\",\n      )}\n    />\n  );\n});\n"
  },
  {
    "path": "src-web/components/core/IconButton.tsx",
    "content": "import classNames from \"classnames\";\nimport type { MouseEvent } from \"react\";\nimport { forwardRef, useCallback } from \"react\";\nimport { useTimedBoolean } from \"../../hooks/useTimedBoolean\";\nimport type { ButtonProps } from \"./Button\";\nimport { Button } from \"./Button\";\nimport type { IconProps } from \"./Icon\";\nimport { Icon } from \"./Icon\";\nimport { LoadingIcon } from \"./LoadingIcon\";\n\nexport type IconButtonProps = IconProps &\n  ButtonProps & {\n    showConfirm?: boolean;\n    iconClassName?: string;\n    iconSize?: IconProps[\"size\"];\n    iconColor?: IconProps[\"color\"];\n    title: string;\n    showBadge?: boolean;\n  };\n\nexport const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(\n  {\n    showConfirm,\n    icon,\n    color = \"default\",\n    spin,\n    onClick,\n    className,\n    iconClassName,\n    tabIndex,\n    size = \"md\",\n    iconSize,\n    showBadge,\n    iconColor,\n    isLoading,\n    type = \"button\",\n    ...props\n  }: IconButtonProps,\n  ref,\n) {\n  const [confirmed, setConfirmed] = useTimedBoolean();\n  const handleClick = useCallback(\n    (e: MouseEvent<HTMLButtonElement>) => {\n      if (showConfirm) setConfirmed();\n      onClick?.(e);\n    },\n    [onClick, setConfirmed, showConfirm],\n  );\n\n  return (\n    <Button\n      ref={ref}\n      aria-hidden={icon === \"empty\"}\n      disabled={icon === \"empty\"}\n      tabIndex={(tabIndex ?? icon === \"empty\") ? -1 : undefined}\n      onClick={handleClick}\n      innerClassName=\"flex items-center justify-center\"\n      size={size}\n      color={color}\n      type={type}\n      className={classNames(\n        className,\n        \"group/button relative flex-shrink-0\",\n        \"!px-0\",\n        size === \"md\" && \"w-md\",\n        size === \"sm\" && \"w-sm\",\n        size === \"xs\" && \"w-xs\",\n        size === \"2xs\" && \"w-5\",\n      )}\n      {...props}\n    >\n      {showBadge && (\n        <div className=\"absolute top-0 right-0 w-1/2 h-1/2 flex items-center justify-center\">\n          <div className=\"w-2.5 h-2.5 bg-pink-500 rounded-full\" />\n        </div>\n      )}\n      {isLoading ? (\n        <LoadingIcon size={iconSize} className={iconClassName} />\n      ) : (\n        <Icon\n          size={iconSize}\n          icon={confirmed ? \"check\" : icon}\n          spin={spin}\n          color={iconColor}\n          className={classNames(\n            iconClassName,\n            \"group-hover/button:text-text\",\n            confirmed && \"!text-success\", // Don't use Icon.color here because it won't override the hover color\n            props.disabled && \"opacity-70\",\n          )}\n        />\n      )}\n    </Button>\n  );\n});\n"
  },
  {
    "path": "src-web/components/core/IconTooltip.tsx",
    "content": "import type { IconProps } from \"./Icon\";\nimport { Icon } from \"./Icon\";\nimport type { TooltipProps } from \"./Tooltip\";\nimport { Tooltip } from \"./Tooltip\";\n\ntype Props = Omit<TooltipProps, \"children\"> & {\n  icon?: IconProps[\"icon\"];\n  iconSize?: IconProps[\"size\"];\n  iconColor?: IconProps[\"color\"];\n  className?: string;\n  tabIndex?: number;\n};\n\nexport function IconTooltip({\n  content,\n  icon = \"info\",\n  iconColor,\n  iconSize,\n  ...tooltipProps\n}: Props) {\n  return (\n    <Tooltip content={content} {...tooltipProps}>\n      <Icon\n        className=\"opacity-60 hover:opacity-100\"\n        icon={icon}\n        size={iconSize}\n        color={iconColor}\n      />\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/InlineCode.tsx",
    "content": "import classNames from \"classnames\";\nimport type { HTMLAttributes } from \"react\";\n\nexport function InlineCode({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {\n  return (\n    <code\n      className={classNames(\n        className,\n        \"font-mono text-shrink bg-surface-highlight border border-border-subtle flex-grow-0\",\n        \"px-1.5 py-0.5 rounded text shadow-inner break-words\",\n      )}\n      {...props}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Input.tsx",
    "content": "import type { EditorView } from \"@codemirror/view\";\nimport type { Color } from \"@yaakapp-internal/plugins\";\nimport classNames from \"classnames\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { createFastMutation } from \"../../hooks/useFastMutation\";\nimport { useIsEncryptionEnabled } from \"../../hooks/useIsEncryptionEnabled\";\nimport { useStateWithDeps } from \"../../hooks/useStateWithDeps\";\nimport { copyToClipboard } from \"../../lib/copy\";\nimport {\n  analyzeTemplate,\n  convertTemplateToInsecure,\n  convertTemplateToSecure,\n} from \"../../lib/encryption\";\nimport { generateId } from \"../../lib/generateId\";\nimport {\n  setupOrConfigureEncryption,\n  withEncryptionEnabled,\n} from \"../../lib/setupOrConfigureEncryption\";\nimport { Button } from \"./Button\";\nimport type { DropdownItem } from \"./Dropdown\";\nimport { Dropdown } from \"./Dropdown\";\nimport type { EditorProps } from \"./Editor/Editor\";\nimport { Editor } from \"./Editor/LazyEditor\";\nimport type { IconProps } from \"./Icon\";\nimport { Icon } from \"./Icon\";\nimport { IconButton } from \"./IconButton\";\nimport { IconTooltip } from \"./IconTooltip\";\nimport { Label } from \"./Label\";\nimport { HStack } from \"./Stacks\";\n\nexport type InputProps = Pick<\n  EditorProps,\n  | \"language\"\n  | \"autocomplete\"\n  | \"forcedEnvironmentId\"\n  | \"forceUpdateKey\"\n  | \"disabled\"\n  | \"autoFocus\"\n  | \"autoSelect\"\n  | \"autocompleteVariables\"\n  | \"autocompleteFunctions\"\n  | \"onKeyDown\"\n  | \"readOnly\"\n> & {\n  className?: string;\n  containerClassName?: string;\n  inputWrapperClassName?: string;\n  defaultValue?: string | null;\n  disableObscureToggle?: boolean;\n  fullHeight?: boolean;\n  hideLabel?: boolean;\n  help?: ReactNode;\n  label: ReactNode;\n  labelClassName?: string;\n  labelPosition?: \"top\" | \"left\";\n  leftSlot?: ReactNode;\n  multiLine?: boolean;\n  name?: string;\n  onBlur?: () => void;\n  onChange?: (value: string) => void;\n  onFocus?: () => void;\n  onPaste?: (value: string) => void;\n  onPasteOverwrite?: EditorProps[\"onPasteOverwrite\"];\n  placeholder?: string;\n  required?: boolean;\n  rightSlot?: ReactNode;\n  size?: \"2xs\" | \"xs\" | \"sm\" | \"md\" | \"auto\";\n  stateKey: EditorProps[\"stateKey\"];\n  extraExtensions?: EditorProps[\"extraExtensions\"];\n  tint?: Color;\n  type?: \"text\" | \"password\";\n  validate?: boolean | ((v: string) => boolean);\n  wrapLines?: boolean;\n  setRef?: (h: InputHandle | null) => void;\n};\n\nexport interface InputHandle {\n  focus: () => void;\n  isFocused: () => boolean;\n  value: () => string;\n  selectAll: () => void;\n  dispatch: EditorView[\"dispatch\"];\n}\n\nexport function Input({ type, ...props }: InputProps) {\n  // If it's a password and template functions are supported (ie. secure(...)) then\n  // use the encrypted input component.\n  if (type === \"password\" && props.autocompleteFunctions) {\n    return <EncryptionInput {...props} />;\n  }\n  return <BaseInput type={type} {...props} />;\n}\n\nfunction BaseInput({\n  className,\n  containerClassName,\n  defaultValue,\n  disableObscureToggle,\n  disabled,\n  forceUpdateKey,\n  fullHeight,\n  help,\n  hideLabel,\n  inputWrapperClassName,\n  label,\n  labelClassName,\n  labelPosition = \"top\",\n  leftSlot,\n  multiLine,\n  onBlur,\n  onChange,\n  onFocus,\n  onPaste,\n  onPasteOverwrite,\n  placeholder,\n  readOnly,\n  required,\n  rightSlot,\n  size = \"md\",\n  stateKey,\n  tint,\n  type = \"text\",\n  validate,\n  wrapLines,\n  setRef,\n  ...props\n}: InputProps) {\n  const [focused, setFocused] = useState(false);\n  const [obscured, setObscured] = useStateWithDeps(type === \"password\", [type]);\n  const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);\n  const editorRef = useRef<EditorView | null>(null);\n  const skipNextFocus = useRef<boolean>(false);\n\n  const handle = useMemo<InputHandle>(\n    () => ({\n      focus: () => {\n        if (editorRef.current == null) return;\n        const anchor = editorRef.current.state.doc.length;\n        skipNextFocus.current = true;\n        editorRef.current.focus();\n        editorRef.current.dispatch({ selection: { anchor, head: anchor }, scrollIntoView: true });\n      },\n      isFocused: () => editorRef.current?.hasFocus ?? false,\n      value: () => editorRef.current?.state.doc.toString() ?? \"\",\n      dispatch: (...args) => {\n        // oxlint-disable-next-line no-explicit-any\n        editorRef.current?.dispatch(...(args as any));\n      },\n      selectAll() {\n        if (editorRef.current == null) return;\n        editorRef.current.focus();\n        editorRef.current.dispatch({\n          selection: { anchor: 0, head: editorRef.current.state.doc.length },\n        });\n      },\n    }),\n    [],\n  );\n\n  const setEditorRef = useCallback(\n    (h: EditorView | null) => {\n      editorRef.current = h;\n      setRef?.(handle);\n    },\n    [handle, setRef],\n  );\n\n  useEffect(() => {\n    const fn = () => {\n      skipNextFocus.current = true;\n    };\n    window.addEventListener(\"focus\", fn);\n    return () => {\n      window.removeEventListener(\"focus\", fn);\n    };\n  }, []);\n\n  const handleFocus = useCallback(() => {\n    if (readOnly) return;\n\n    if (!skipNextFocus.current) {\n      editorRef.current?.dispatch({\n        selection: { anchor: 0, head: editorRef.current.state.doc.length },\n      });\n    }\n\n    setFocused(true);\n    onFocus?.();\n    skipNextFocus.current = false;\n  }, [onFocus, readOnly]);\n\n  const handleBlur = useCallback(async () => {\n    setFocused(false);\n    // Move selection to the end on blur\n    const anchor = editorRef.current?.state.doc.length ?? 0;\n    editorRef.current?.dispatch({\n      selection: { anchor, head: anchor },\n    });\n    onBlur?.();\n  }, [onBlur]);\n\n  const id = useRef(`input-${generateId()}`);\n  const editorClassName = classNames(\n    className,\n    \"!bg-transparent min-w-0 h-auto w-full focus:outline-none placeholder:text-placeholder\",\n  );\n\n  const isValid = useMemo(() => {\n    if (required && !validateRequire(defaultValue ?? \"\")) return false;\n    if (typeof validate === \"boolean\") return validate;\n    if (typeof validate === \"function\" && !validate(defaultValue ?? \"\")) return false;\n    return true;\n  }, [required, defaultValue, validate]);\n\n  const handleChange = useCallback(\n    (value: string) => {\n      onChange?.(value);\n      setHasChanged(true);\n    },\n    [onChange, setHasChanged],\n  );\n\n  const wrapperRef = useRef<HTMLDivElement>(null);\n\n  // Submit the nearest form on Enter key press\n  const handleKeyDown = useCallback(\n    (e: KeyboardEvent) => {\n      if (e.key !== \"Enter\") return;\n\n      const form = wrapperRef.current?.closest(\"form\");\n      if (!isValid || form == null) return;\n\n      form?.dispatchEvent(new Event(\"submit\", { cancelable: true, bubbles: true }));\n    },\n    [isValid],\n  );\n\n  return (\n    <div\n      ref={wrapperRef}\n      className={classNames(\n        \"pointer-events-auto\", // Just in case we're placing in disabled parent\n        \"w-full\",\n        fullHeight && \"h-full\",\n        labelPosition === \"left\" && \"flex items-center gap-2\",\n        labelPosition === \"top\" && \"flex-row gap-0.5\",\n      )}\n    >\n      <Label\n        htmlFor={id.current}\n        help={help}\n        required={required}\n        visuallyHidden={hideLabel}\n        className={classNames(labelClassName)}\n      >\n        {label}\n      </Label>\n      <HStack\n        alignItems=\"stretch\"\n        className={classNames(\n          containerClassName,\n          fullHeight && \"h-full\",\n          \"x-theme-input\",\n          \"relative w-full rounded-md text overflow-hidden\",\n          \"border\",\n          focused && !disabled ? \"border-border-focus\" : \"border-border\",\n          disabled && \"border-dotted\",\n          !isValid && hasChanged && \"!border-danger\",\n          size === \"md\" && \"min-h-md\",\n          size === \"sm\" && \"min-h-sm\",\n          size === \"xs\" && \"min-h-xs\",\n          size === \"2xs\" && \"min-h-2xs\",\n        )}\n      >\n        {tint != null && (\n          <div\n            aria-hidden\n            className={classNames(\n              \"absolute inset-0 opacity-5 pointer-events-none\",\n              tint === \"primary\" && \"bg-primary\",\n              tint === \"secondary\" && \"bg-secondary\",\n              tint === \"info\" && \"bg-info\",\n              tint === \"success\" && \"bg-success\",\n              tint === \"notice\" && \"bg-notice\",\n              tint === \"warning\" && \"bg-warning\",\n              tint === \"danger\" && \"bg-danger\",\n            )}\n          />\n        )}\n        {leftSlot}\n        <HStack\n          className={classNames(\n            inputWrapperClassName,\n            \"w-full min-w-0 px-2\",\n            fullHeight && \"h-full\",\n            leftSlot ? \"pl-0.5 -ml-2\" : null,\n            rightSlot ? \"pr-0.5 -mr-2\" : null,\n          )}\n        >\n          <Editor\n            setRef={setEditorRef}\n            id={id.current}\n            hideGutter\n            singleLine={!multiLine}\n            containerOnly\n            stateKey={stateKey}\n            wrapLines={wrapLines}\n            heightMode=\"auto\"\n            onKeyDown={handleKeyDown}\n            type={type === \"password\" && !obscured ? \"text\" : type}\n            defaultValue={defaultValue}\n            forceUpdateKey={forceUpdateKey}\n            placeholder={placeholder}\n            onChange={handleChange}\n            onPaste={onPaste}\n            onPasteOverwrite={onPasteOverwrite}\n            disabled={disabled}\n            className={classNames(\n              editorClassName,\n              multiLine && size === \"md\" && \"py-1.5\",\n              multiLine && size === \"sm\" && \"py-1\",\n            )}\n            onFocus={handleFocus}\n            onBlur={handleBlur}\n            readOnly={readOnly}\n            {...props}\n          />\n        </HStack>\n        {type === \"password\" && !disableObscureToggle && (\n          <IconButton\n            title={\n              obscured\n                ? `Show ${typeof label === \"string\" ? label : \"field\"}`\n                : `Obscure ${typeof label === \"string\" ? label : \"field\"}`\n            }\n            size=\"xs\"\n            className={classNames(\"mr-0.5 !h-auto my-0.5\", disabled && \"opacity-disabled\")}\n            color={tint}\n            // iconClassName={classNames(\n            //   tint === 'primary' && 'text-primary',\n            //   tint === 'secondary' && 'text-secondary',\n            //   tint === 'info' && 'text-info',\n            //   tint === 'success' && 'text-success',\n            //   tint === 'notice' && 'text-notice',\n            //   tint === 'warning' && 'text-warning',\n            //   tint === 'danger' && 'text-danger',\n            // )}\n            iconSize=\"sm\"\n            icon={obscured ? \"eye\" : \"eye_closed\"}\n            onClick={() => setObscured((o) => !o)}\n          />\n        )}\n        {rightSlot}\n      </HStack>\n    </div>\n  );\n}\n\nfunction validateRequire(v: string) {\n  return v.length > 0;\n}\n\ntype PasswordFieldType = \"text\" | \"encrypted\";\n\nfunction EncryptionInput({\n  defaultValue,\n  onChange,\n  autocompleteFunctions,\n  autocompleteVariables,\n  forceUpdateKey: ogForceUpdateKey,\n  setRef,\n  ...props\n}: InputProps) {\n  const isEncryptionEnabled = useIsEncryptionEnabled();\n  const [state, setState] = useStateWithDeps<{\n    fieldType: PasswordFieldType;\n    value: string | null;\n    security: ReturnType<typeof analyzeTemplate> | null;\n    obscured: boolean;\n    error: string | null;\n  }>(\n    {\n      fieldType: isEncryptionEnabled ? \"encrypted\" : \"text\",\n      value: null,\n      security: null,\n      obscured: true,\n      error: null,\n    },\n    [ogForceUpdateKey],\n  );\n\n  const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === null}`;\n  const inputRef = useRef<InputHandle>(null);\n\n  useEffect(() => {\n    if (state.value != null) {\n      // We already configured it\n      return;\n    }\n\n    const security = analyzeTemplate(defaultValue ?? \"\");\n    if (analyzeTemplate(defaultValue ?? \"\") === \"global_secured\") {\n      // Lazily update value to decrypted representation\n      templateToInsecure.mutate(defaultValue ?? \"\", {\n        onSuccess: (value) => {\n          setState({ fieldType: \"encrypted\", security, value, obscured: true, error: null });\n          // We're calling this here because we want the input to be fully initialized so the caller\n          // can do stuff like change the selection.\n          requestAnimationFrame(() => setRef?.(inputRef.current));\n        },\n        onError: (value) => {\n          setState({\n            fieldType: \"encrypted\",\n            security,\n            value: null,\n            error: String(value),\n            obscured: true,\n          });\n        },\n      });\n    } else if (isEncryptionEnabled && !defaultValue) {\n      // Default to encrypted field for new encrypted inputs\n      setState({ fieldType: \"encrypted\", security, value: \"\", obscured: true, error: null });\n      requestAnimationFrame(() => setRef?.(inputRef.current));\n    } else if (isEncryptionEnabled) {\n      // Don't obscure plain text when encryption is enabled\n      setState({\n        fieldType: \"text\",\n        security,\n        value: defaultValue ?? \"\",\n        obscured: false,\n        error: null,\n      });\n      requestAnimationFrame(() => setRef?.(inputRef.current));\n    } else {\n      // Don't obscure plain text when encryption is disabled\n      setState({\n        fieldType: \"text\",\n        security,\n        value: defaultValue ?? \"\",\n        obscured: true,\n        error: null,\n      });\n      requestAnimationFrame(() => setRef?.(inputRef.current));\n    }\n  }, [defaultValue, isEncryptionEnabled, setRef, setState, state.value]);\n\n  const handleChange = useCallback(\n    (value: string, fieldType: PasswordFieldType) => {\n      if (fieldType === \"encrypted\") {\n        templateToSecure.mutate(value, { onSuccess: (value) => onChange?.(value) });\n      } else {\n        onChange?.(value);\n      }\n      setState((s) => {\n        // We can't analyze when encrypted because we don't have the raw value, so assume it's secured\n        const security = fieldType === \"encrypted\" ? \"global_secured\" : analyzeTemplate(value);\n        // Reset obscured value when the field type is being changed\n        const obscured = fieldType === s.fieldType ? s.obscured : fieldType !== \"text\";\n        return { fieldType, value, security, obscured, error: s.error };\n      });\n    },\n    [onChange, setState],\n  );\n\n  const handleInputChange = useCallback(\n    (value: string) => {\n      if (state.fieldType != null) {\n        handleChange(value, state.fieldType);\n      }\n    },\n    [handleChange, state],\n  );\n\n  const setInputRef = useCallback((h: InputHandle | null) => {\n    inputRef.current = h;\n  }, []);\n\n  const handleFieldTypeChange = useCallback(\n    (newFieldType: PasswordFieldType) => {\n      const { value, fieldType } = state;\n      if (value == null || fieldType === newFieldType) {\n        return;\n      }\n\n      withEncryptionEnabled(async () => {\n        const newValue = await convertTemplateToInsecure(value);\n        handleChange(newValue, newFieldType);\n      });\n    },\n    [handleChange, state],\n  );\n\n  const dropdownItems = useMemo<DropdownItem[]>(\n    () => [\n      {\n        label: state.obscured ? \"Show\" : \"Hide\",\n        disabled: isEncryptionEnabled && state.fieldType === \"text\",\n        leftSlot: <Icon icon={state.obscured ? \"eye\" : \"eye_closed\"} />,\n        onSelect: () => setState((s) => ({ ...s, obscured: !s.obscured })),\n      },\n      {\n        label: \"Copy\",\n        leftSlot: <Icon icon=\"copy\" />,\n        hidden: !state.value,\n        onSelect: () => copyToClipboard(state.value ?? \"\"),\n      },\n      { type: \"separator\" },\n      {\n        label: state.fieldType === \"text\" ? \"Encrypt Field\" : \"Decrypt Field\",\n        leftSlot: <Icon icon={state.fieldType === \"text\" ? \"lock\" : \"lock_open\"} />,\n        onSelect: () => handleFieldTypeChange(state.fieldType === \"text\" ? \"encrypted\" : \"text\"),\n      },\n    ],\n    [\n      handleFieldTypeChange,\n      isEncryptionEnabled,\n      setState,\n      state.fieldType,\n      state.obscured,\n      state.value,\n    ],\n  );\n\n  let tint: InputProps[\"tint\"];\n  if (!isEncryptionEnabled) {\n    tint = undefined;\n  } else if (state.fieldType === \"encrypted\") {\n    tint = \"info\";\n  } else if (state.security === \"local_secured\") {\n    tint = \"secondary\";\n  } else if (state.security === \"insecure\") {\n    tint = \"notice\";\n  }\n\n  const rightSlot = useMemo(() => {\n    let icon: IconProps[\"icon\"];\n    if (isEncryptionEnabled) {\n      icon = state.security === \"insecure\" ? \"shield_off\" : \"shield_check\";\n    } else {\n      icon = state.obscured ? \"eye_closed\" : \"eye\";\n    }\n    return (\n      <HStack className=\"h-auto m-0.5\">\n        <Dropdown items={dropdownItems}>\n          <Button\n            size=\"sm\"\n            variant=\"border\"\n            color={tint}\n            aria-label=\"Configure encryption\"\n            className={classNames(\n              \"flex items-center justify-center !h-full !px-1\",\n              \"opacity-70\", // Makes it a bit subtler\n              props.disabled && \"!opacity-disabled\",\n            )}\n          >\n            <HStack space={0.5}>\n              <Icon size=\"sm\" title=\"Configure encryption\" icon={icon} />\n              <Icon size=\"xs\" title=\"Configure encryption\" icon=\"chevron_down\" />\n            </HStack>\n          </Button>\n        </Dropdown>\n      </HStack>\n    );\n  }, [dropdownItems, isEncryptionEnabled, props.disabled, state.obscured, state.security, tint]);\n\n  const type = state.obscured ? \"password\" : \"text\";\n\n  if (state.error) {\n    return (\n      <Button\n        variant=\"border\"\n        color=\"danger\"\n        size={props.size}\n        className=\"text-sm\"\n        rightSlot={<IconTooltip tabIndex={-1} content={state.error} icon=\"alert_triangle\" />}\n        onClick={() => {\n          setupOrConfigureEncryption();\n        }}\n      >\n        {state.error.replace(/^Render Error: /i, \"\")}\n      </Button>\n    );\n  }\n\n  return (\n    <BaseInput\n      setRef={setInputRef}\n      disableObscureToggle\n      autocompleteFunctions={autocompleteFunctions}\n      autocompleteVariables={autocompleteVariables}\n      defaultValue={state.value ?? \"\"}\n      forceUpdateKey={forceUpdateKey}\n      onChange={handleInputChange}\n      tint={tint}\n      type={type}\n      rightSlot={rightSlot}\n      disabled={state.error != null}\n      className=\"pr-1.5\" // To account for encryption dropdown\n      {...props}\n    />\n  );\n}\n\nconst templateToSecure = createFastMutation({\n  mutationKey: [\"template-to-secure\"],\n  mutationFn: convertTemplateToSecure,\n});\n\nconst templateToInsecure = createFastMutation({\n  mutationKey: [\"template-to-insecure\"],\n  mutationFn: convertTemplateToInsecure,\n  disableToastError: true,\n});\n"
  },
  {
    "path": "src-web/components/core/JsonAttributeTree.tsx",
    "content": "import classNames from \"classnames\";\nimport type { ReactNode } from \"react\";\nimport { useMemo, useState } from \"react\";\nimport { Icon } from \"./Icon\";\n\ninterface Props {\n  depth?: number;\n  // oxlint-disable-next-line no-explicit-any\n  attrValue: any;\n  attrKey?: string | number;\n  attrKeyJsonPath?: string;\n  className?: string;\n}\n\nexport const JsonAttributeTree = ({\n  depth = 0,\n  attrKey,\n  attrValue,\n  attrKeyJsonPath,\n  className,\n}: Props) => {\n  attrKeyJsonPath = attrKeyJsonPath ?? `${attrKey}`;\n\n  const [isExpanded, setIsExpanded] = useState(true);\n  const toggleExpanded = () => setIsExpanded((v) => !v);\n\n  const { isExpandable, children, label, labelClassName } = useMemo<{\n    isExpandable: boolean;\n    children: ReactNode;\n    label?: string;\n    labelClassName?: string;\n  }>(() => {\n    const jsonType = Object.prototype.toString.call(attrValue);\n    if (jsonType === \"[object Object]\") {\n      return {\n        children: isExpanded\n          ? Object.keys(attrValue)\n              .sort((a, b) => a.localeCompare(b))\n              .flatMap((k) => (\n                <JsonAttributeTree\n                  key={k}\n                  depth={depth + 1}\n                  attrValue={attrValue[k]}\n                  attrKey={k}\n                  attrKeyJsonPath={joinObjectKey(attrKeyJsonPath, k)}\n                />\n              ))\n          : null,\n        isExpandable: Object.keys(attrValue).length > 0,\n        label: isExpanded ? `{${Object.keys(attrValue).length || \" \"}}` : \"{⋯}\",\n        labelClassName: \"text-text-subtlest\",\n      };\n    }\n    if (jsonType === \"[object Array]\") {\n      return {\n        children: isExpanded\n          ? // oxlint-disable-next-line no-explicit-any\n            attrValue.flatMap((v: any, i: number) => (\n              <JsonAttributeTree\n                // oxlint-disable-next-line react/no-array-index-key\n                key={i}\n                depth={depth + 1}\n                attrValue={v}\n                attrKey={i}\n                attrKeyJsonPath={joinArrayKey(attrKeyJsonPath, i)}\n              />\n            ))\n          : null,\n        isExpandable: attrValue.length > 0,\n        label: isExpanded ? `[${attrValue.length || \" \"}]` : \"[⋯]\",\n        labelClassName: \"text-text-subtlest\",\n      };\n    }\n    return {\n      children: null,\n      isExpandable: false,\n      label: jsonType === \"[object String]\" ? `\"${attrValue}\"` : `${attrValue}`,\n      labelClassName: classNames(\n        jsonType === \"[object Boolean]\" && \"text-primary\",\n        jsonType === \"[object Number]\" && \"text-info\",\n        jsonType === \"[object String]\" && \"text-notice\",\n        jsonType === \"[object Null]\" && \"text-danger\",\n      ),\n    };\n  }, [attrValue, attrKeyJsonPath, isExpanded, depth]);\n\n  const labelEl = (\n    <span\n      className={classNames(labelClassName, \"cursor-text select-text group-hover:text-text-subtle\")}\n    >\n      {label}\n    </span>\n  );\n  return (\n    <div\n      className={classNames(\n        className,\n        /*depth === 0 && '-ml-4',*/ \"font-mono text-xs\",\n        depth === 0 && \"h-full overflow-y-auto pb-2\",\n      )}\n    >\n      <div className=\"flex items-center\">\n        {isExpandable ? (\n          <button\n            type=\"button\"\n            className=\"group relative flex items-center pl-4 w-full\"\n            onClick={toggleExpanded}\n          >\n            <Icon\n              size=\"xs\"\n              icon=\"chevron_right\"\n              className={classNames(\n                \"left-0 absolute transition-transform flex items-center\",\n                \"group-hover:text-text-subtle\",\n                isExpanded ? \"rotate-90\" : \"\",\n              )}\n            />\n            <span className=\"text-primary group-hover:text-primary mr-1.5 whitespace-nowrap\">\n              {attrKey === undefined ? \"$\" : attrKey}:\n            </span>\n            {labelEl}\n          </button>\n        ) : (\n          <>\n            <span className=\"text-primary mr-1.5 pl-4 whitespace-nowrap cursor-text select-text\">\n              {attrKey}:\n            </span>\n            {labelEl}\n          </>\n        )}\n      </div>\n      {children && <div className=\"ml-4 whitespace-nowrap\">{children}</div>}\n    </div>\n  );\n};\n\nfunction joinObjectKey(baseKey: string | undefined, key: string): string {\n  const quotedKey = key.match(/^[a-z0-9_]+$/i) ? key : `\\`${key}\\``;\n\n  if (baseKey == null) return quotedKey;\n  return `${baseKey}.${quotedKey}`;\n}\n\nfunction joinArrayKey(baseKey: string | undefined, index: number): string {\n  return `${baseKey ?? \"\"}[${index}]`;\n}\n"
  },
  {
    "path": "src-web/components/core/KeyValueRow.tsx",
    "content": "import classNames from \"classnames\";\nimport type { HTMLAttributes, ReactElement, ReactNode } from \"react\";\n\ninterface Props {\n  children:\n    | ReactElement<HTMLAttributes<HTMLTableColElement>>\n    | (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];\n}\n\nexport function KeyValueRows({ children }: Props) {\n  const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];\n  return (\n    <table className=\"text-editor font-mono min-w-0 w-full mb-auto\">\n      <tbody className=\"divide-y divide-surface-highlight\">\n        {childArray.map((child, i) => (\n          // oxlint-disable-next-line react/no-array-index-key\n          <tr key={i}>{child}</tr>\n        ))}\n      </tbody>\n    </table>\n  );\n}\n\ninterface KeyValueRowProps {\n  label: ReactNode;\n  children: ReactNode;\n  rightSlot?: ReactNode;\n  leftSlot?: ReactNode;\n  labelClassName?: string;\n  labelColor?: \"secondary\" | \"primary\" | \"info\";\n}\n\nexport function KeyValueRow({\n  label,\n  children,\n  rightSlot,\n  leftSlot,\n  labelColor = \"secondary\",\n  labelClassName,\n}: KeyValueRowProps) {\n  return (\n    <>\n      <td\n        className={classNames(\n          \"select-none py-0.5 pr-2 h-full align-top max-w-[10rem]\",\n          labelClassName,\n          labelColor === \"primary\" && \"text-primary\",\n          labelColor === \"secondary\" && \"text-text-subtle\",\n          labelColor === \"info\" && \"text-info\",\n        )}\n      >\n        <span className=\"select-text cursor-text\">{label}</span>\n      </td>\n      <td className=\"select-none py-0.5 break-all align-top max-w-[15rem]\">\n        <div className=\"select-text cursor-text max-h-[12rem] overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]\">\n          {leftSlot ?? <span aria-hidden />}\n          {children}\n          {rightSlot ? <div className=\"ml-1.5\">{rightSlot}</div> : <span aria-hidden />}\n        </div>\n      </td>\n    </>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Label.tsx",
    "content": "import classNames from \"classnames\";\nimport type { HTMLAttributes, ReactNode } from \"react\";\nimport { IconTooltip } from \"./IconTooltip\";\n\nexport function Label({\n  htmlFor,\n  className,\n  children,\n  visuallyHidden,\n  tags = [],\n  required,\n  rightSlot,\n  help,\n  ...props\n}: HTMLAttributes<HTMLLabelElement> & {\n  htmlFor: string | null;\n  required?: boolean;\n  tags?: string[];\n  visuallyHidden?: boolean;\n  rightSlot?: ReactNode;\n  children: ReactNode;\n  help?: ReactNode;\n}) {\n  return (\n    <label\n      htmlFor={htmlFor ?? undefined}\n      className={classNames(\n        className,\n        visuallyHidden && \"sr-only\",\n        \"flex-shrink-0 text-sm\",\n        \"text-text-subtle whitespace-nowrap flex items-center gap-1 mb-0.5\",\n      )}\n      {...props}\n    >\n      <span>\n        {children}\n        {required === true && <span className=\"text-text-subtlest\">*</span>}\n      </span>\n      {tags.map((tag, i) => (\n        // oxlint-disable-next-line react/no-array-index-key\n        <span key={i} className=\"text-xs text-text-subtlest\">\n          ({tag})\n        </span>\n      ))}\n      {help && <IconTooltip tabIndex={-1} content={help} />}\n      {rightSlot && <div className=\"ml-auto\">{rightSlot}</div>}\n    </label>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Link.tsx",
    "content": "import { Link as RouterLink } from \"@tanstack/react-router\";\nimport classNames from \"classnames\";\nimport type { HTMLAttributes } from \"react\";\nimport { appInfo } from \"../../lib/appInfo\";\nimport { Icon } from \"./Icon\";\n\ninterface Props extends HTMLAttributes<HTMLAnchorElement> {\n  href: string;\n  noUnderline?: boolean;\n}\n\nexport function Link({ href, children, noUnderline, className, ...other }: Props) {\n  const isExternal = href.match(/^https?:\\/\\//);\n\n  className = classNames(\n    className,\n    \"relative\",\n    \"inline-flex items-center hover:underline group\",\n    !noUnderline && \"underline\",\n  );\n\n  if (isExternal) {\n    const isYaakLink = href.startsWith(\"https://yaak.app\");\n    let finalHref = href;\n    if (isYaakLink) {\n      const url = new URL(href);\n      url.searchParams.set(\"ref\", appInfo.identifier);\n      finalHref = url.toString();\n    }\n    return (\n      // eslint-disable-next-line react/jsx-no-target-blank\n      <a\n        href={finalHref}\n        target=\"_blank\"\n        rel={isYaakLink ? undefined : \"noopener noreferrer\"}\n        onClick={(e) => e.preventDefault()}\n        className={className}\n        {...other}\n      >\n        <span className=\"pr-5\">{children}</span>\n        <Icon\n          className=\"inline absolute right-0.5 top-[0.3em] opacity-70 group-hover:opacity-100\"\n          size=\"xs\"\n          icon=\"external_link\"\n        />\n      </a>\n    );\n  }\n\n  return (\n    <RouterLink to={href} className={className} {...other}>\n      {children}\n    </RouterLink>\n  );\n}\n\nexport function FeedbackLink() {\n  return <Link href=\"https://yaak.app/roadmap\">Feedback</Link>;\n}\n"
  },
  {
    "path": "src-web/components/core/LoadingIcon.tsx",
    "content": "import classNames from \"classnames\";\n\ninterface Props {\n  size?: \"2xs\" | \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";\n  className?: string;\n}\n\nexport function LoadingIcon({ size = \"md\", className }: Props) {\n  const classes = classNames(\n    className,\n    \"text-inherit flex-shrink-0\",\n    size === \"xl\" && \"h-6 w-6\",\n    size === \"lg\" && \"h-5 w-5\",\n    size === \"md\" && \"h-4 w-4\",\n    size === \"sm\" && \"h-3.5 w-3.5\",\n    size === \"xs\" && \"h-3 w-3\",\n    size === \"2xs\" && \"h-2.5 w-2.5\",\n    \"animate-spin\",\n  );\n\n  return (\n    <div\n      className={classNames(\n        classes,\n        \"border-[currentColor] border-b-transparent rounded-full\",\n        size === \"xl\" && \"border-[0.2rem]\",\n        size === \"lg\" && \"border-[0.16rem]\",\n        size === \"md\" && \"border-[0.13rem]\",\n        size === \"sm\" && \"border-[0.1rem]\",\n        size === \"xs\" && \"border-[0.08rem]\",\n        size === \"2xs\" && \"border-[0.06rem]\",\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/PairEditor.tsx",
    "content": "import type { DragEndEvent, DragMoveEvent, DragStartEvent } from \"@dnd-kit/core\";\nimport {\n  DndContext,\n  DragOverlay,\n  PointerSensor,\n  pointerWithin,\n  useDraggable,\n  useDroppable,\n  useSensor,\n  useSensors,\n} from \"@dnd-kit/core\";\nimport { basename } from \"@tauri-apps/api/path\";\nimport classNames from \"classnames\";\nimport { Fragment, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { WrappedEnvironmentVariable } from \"../../hooks/useEnvironmentVariables\";\nimport { useRandomKey } from \"../../hooks/useRandomKey\";\nimport { useToggle } from \"../../hooks/useToggle\";\nimport { languageFromContentType } from \"../../lib/contentType\";\nimport { showDialog } from \"../../lib/dialog\";\nimport { computeSideForDragMove } from \"../../lib/dnd\";\nimport { showPrompt } from \"../../lib/prompt\";\nimport { DropMarker } from \"../DropMarker\";\nimport { SelectFile } from \"../SelectFile\";\nimport { Button } from \"./Button\";\nimport { Checkbox } from \"./Checkbox\";\nimport type { DropdownItem } from \"./Dropdown\";\nimport { Dropdown } from \"./Dropdown\";\nimport type { EditorProps } from \"./Editor/Editor\";\nimport type { GenericCompletionConfig } from \"./Editor/genericCompletion\";\nimport { Editor } from \"./Editor/LazyEditor\";\nimport { Icon } from \"./Icon\";\nimport { IconButton } from \"./IconButton\";\nimport type { InputHandle, InputProps } from \"./Input\";\nimport { Input } from \"./Input\";\nimport { ensurePairId } from \"./PairEditor.util\";\nimport type { RadioDropdownItem } from \"./RadioDropdown\";\nimport { RadioDropdown } from \"./RadioDropdown\";\n\nexport interface PairEditorHandle {\n  focusName(id: string): void;\n  focusValue(id: string): void;\n}\n\nexport type PairEditorProps = {\n  allowFileValues?: boolean;\n  allowMultilineValues?: boolean;\n  className?: string;\n  forcedEnvironmentId?: string;\n  forceUpdateKey?: string;\n  nameAutocomplete?: GenericCompletionConfig;\n  nameAutocompleteFunctions?: boolean;\n  nameAutocompleteVariables?: boolean;\n  namePlaceholder?: string;\n  nameValidate?: InputProps[\"validate\"];\n  noScroll?: boolean;\n  onChange: (pairs: PairWithId[]) => void;\n  pairs: Pair[];\n  stateKey: InputProps[\"stateKey\"];\n  setRef?: (n: PairEditorHandle) => void;\n  valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;\n  valueAutocompleteFunctions?: boolean;\n  valueAutocompleteVariables?: boolean | \"environment\";\n  valuePlaceholder?: string;\n  valueType?: InputProps[\"type\"] | ((pair: Pair) => InputProps[\"type\"]);\n  valueValidate?: InputProps[\"validate\"];\n};\n\nexport type Pair = {\n  id?: string;\n  enabled?: boolean;\n  name: string;\n  value: string;\n  contentType?: string;\n  filename?: string;\n  isFile?: boolean;\n  readOnlyName?: boolean;\n};\n\nexport type PairWithId = Pair & {\n  id: string;\n};\n\n/** Max number of pairs to show before prompting the user to reveal the rest */\nconst MAX_INITIAL_PAIRS = 30;\n\nexport function PairEditor({\n  allowFileValues,\n  allowMultilineValues,\n  className,\n  forcedEnvironmentId,\n  forceUpdateKey,\n  nameAutocomplete,\n  nameAutocompleteFunctions,\n  nameAutocompleteVariables,\n  namePlaceholder,\n  nameValidate,\n  noScroll,\n  onChange,\n  pairs: originalPairs,\n  stateKey,\n  valueAutocomplete,\n  valueAutocompleteFunctions,\n  valueAutocompleteVariables,\n  valuePlaceholder,\n  valueType,\n  valueValidate,\n  setRef,\n}: PairEditorProps) {\n  const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);\n  const [isDragging, setIsDragging] = useState<PairWithId | null>(null);\n  const [pairs, setPairs] = useState<PairWithId[]>([]);\n  const [showAll, toggleShowAll] = useToggle(false);\n  // NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If\n  //  we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.\n  const [localForceUpdateKey, regenerateLocalForceUpdateKey] = useRandomKey();\n\n  const rowsRef = useRef<Record<string, RowHandle | null>>({});\n\n  const handle = useMemo<PairEditorHandle>(\n    () => ({\n      focusName(id: string) {\n        rowsRef.current[id]?.focusName();\n      },\n      focusValue(id: string) {\n        rowsRef.current[id]?.focusValue();\n      },\n    }),\n    [],\n  );\n\n  const initPairEditorRow = useCallback(\n    (id: string, n: RowHandle | null) => {\n      const isLast = id === pairs[pairs.length - 1]?.id;\n      if (isLast) return; // Never add the last pair\n\n      rowsRef.current[id] = n;\n      const validHandles = Object.values(rowsRef.current).filter((v) => v != null);\n\n      // Use >= because more might be added if an ID of one changes (eg. editing placeholder in URL regenerates fresh pairs every keystroke)\n      const ready = validHandles.length >= pairs.length - 1;\n      if (ready) {\n        setRef?.(handle);\n      }\n    },\n    [handle, pairs, setRef],\n  );\n\n  // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey\n  useEffect(() => {\n    // Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)\n    const newPairs: PairWithId[] = [];\n    for (let i = 0; i < originalPairs.length; i++) {\n      const p = originalPairs[i];\n      if (!p) continue; // Make TS happy\n      if (isPairEmpty(p)) continue;\n      newPairs.push(ensurePairId(p));\n    }\n\n    // Add empty last pair if there is none\n    const lastPair = newPairs[newPairs.length - 1];\n    if (lastPair == null || !isPairEmpty(lastPair)) {\n      newPairs.push(emptyPair());\n    }\n\n    setPairs(newPairs);\n    regenerateLocalForceUpdateKey();\n  }, [forceUpdateKey]);\n\n  const setPairsAndSave = useCallback(\n    (fn: (pairs: PairWithId[]) => PairWithId[]) => {\n      setPairs((oldPairs) => {\n        const pairs = fn(oldPairs);\n        onChange(pairs);\n        return pairs;\n      });\n    },\n    [onChange],\n  );\n\n  const handleChange = useCallback(\n    (pair: PairWithId) =>\n      setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),\n    [setPairsAndSave],\n  );\n\n  const handleDelete = useCallback(\n    (pair: Pair, focusPrevious: boolean) => {\n      if (focusPrevious) {\n        const index = pairs.findIndex((p) => p.id === pair.id);\n        const id = pairs[index - 1]?.id ?? null;\n        rowsRef.current[id ?? \"n/a\"]?.focusName();\n      }\n      return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));\n    },\n    [setPairsAndSave, pairs],\n  );\n\n  const handleFocusName = useCallback(\n    (pair: Pair) => {\n      const isLast = pair.id === pairs[pairs.length - 1]?.id;\n      if (isLast) setPairs([...pairs, emptyPair()]);\n    },\n    [pairs],\n  );\n\n  const handleFocusValue = useCallback(\n    (pair: Pair) => {\n      const isLast = pair.id === pairs[pairs.length - 1]?.id;\n      if (isLast) setPairs([...pairs, emptyPair()]);\n    },\n    [pairs],\n  );\n\n  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));\n\n  // dnd-kit: show the “between rows” marker while hovering\n  const onDragMove = useCallback(\n    (e: DragMoveEvent) => {\n      const overId = e.over?.id as string | undefined;\n      if (!overId) return setHoveredIndex(null);\n\n      const overPair = pairs.find((p) => p.id === overId);\n      if (overPair == null) return setHoveredIndex(null);\n\n      const side = computeSideForDragMove(overPair.id, e);\n      const overIndex = pairs.findIndex((p) => p.id === overId);\n      const hoveredIndex = overIndex + (side === \"before\" ? 0 : 1);\n\n      setHoveredIndex(hoveredIndex);\n    },\n    [pairs],\n  );\n\n  const onDragStart = useCallback(\n    (e: DragStartEvent) => {\n      const pair = pairs.find((p) => p.id === e.active.id);\n      setIsDragging(pair ?? null);\n    },\n    [pairs],\n  );\n\n  const onDragCancel = useCallback(() => setIsDragging(null), []);\n\n  const onDragEnd = useCallback(\n    (e: DragEndEvent) => {\n      setIsDragging(null);\n      setHoveredIndex(null);\n      const activeId = e.active.id as string | undefined;\n      const overId = e.over?.id as string | undefined;\n      if (!activeId || !overId) return;\n\n      const from = pairs.findIndex((p) => p.id === activeId);\n      const baseTo = pairs.findIndex((p) => p.id === overId);\n      const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo);\n\n      if (from !== -1 && to !== -1 && from !== to) {\n        setPairsAndSave((ps) => {\n          const next = [...ps];\n          const [moved] = next.splice(from, 1);\n          if (moved === undefined) return ps; // Make TS happy\n          next.splice(to > from ? to - 1 : to, 0, moved);\n          return next;\n        });\n      }\n    },\n    [pairs, hoveredIndex, setPairsAndSave],\n  );\n\n  return (\n    <div\n      className={classNames(\n        className,\n        \"@container relative\",\n        \"pb-2 mb-auto h-full\",\n        !noScroll && \"overflow-y-auto max-h-full\",\n        // Move over the width of the drag handle\n        \"-mr-2 pr-2\",\n        // Pad to make room for the drag divider\n        \"pt-0.5\",\n        \"grid grid-rows-[auto_1fr]\",\n      )}\n    >\n      <div>\n        <DndContext\n          autoScroll\n          sensors={sensors}\n          onDragMove={onDragMove}\n          onDragEnd={onDragEnd}\n          onDragStart={onDragStart}\n          onDragCancel={onDragCancel}\n          collisionDetection={pointerWithin}\n        >\n          {pairs.map((p, i) => {\n            if (!showAll && i > MAX_INITIAL_PAIRS) return null;\n\n            const isLast = i === pairs.length - 1;\n            return (\n              <Fragment key={p.id}>\n                {hoveredIndex === i && <DropMarker />}\n                <PairEditorRow\n                  setRef={initPairEditorRow}\n                  allowFileValues={allowFileValues}\n                  allowMultilineValues={allowMultilineValues}\n                  className=\"py-1\"\n                  forcedEnvironmentId={forcedEnvironmentId}\n                  forceUpdateKey={localForceUpdateKey}\n                  index={i}\n                  isLast={isLast}\n                  isDraggingGlobal={!!isDragging}\n                  nameAutocomplete={nameAutocomplete}\n                  nameAutocompleteFunctions={nameAutocompleteFunctions}\n                  nameAutocompleteVariables={nameAutocompleteVariables}\n                  namePlaceholder={namePlaceholder}\n                  nameValidate={nameValidate}\n                  onChange={handleChange}\n                  onDelete={handleDelete}\n                  onFocusName={handleFocusName}\n                  onFocusValue={handleFocusValue}\n                  pair={p}\n                  stateKey={stateKey}\n                  valueAutocomplete={valueAutocomplete}\n                  valueAutocompleteFunctions={valueAutocompleteFunctions}\n                  valueAutocompleteVariables={valueAutocompleteVariables}\n                  valuePlaceholder={valuePlaceholder}\n                  valueType={valueType}\n                  valueValidate={valueValidate}\n                />\n              </Fragment>\n            );\n          })}\n          {!showAll && pairs.length > MAX_INITIAL_PAIRS && (\n            <Button onClick={toggleShowAll} variant=\"border\" className=\"m-2\" size=\"xs\">\n              Show {pairs.length - MAX_INITIAL_PAIRS} More\n            </Button>\n          )}\n          <DragOverlay dropAnimation={null}>\n            {isDragging && (\n              <PairEditorRow\n                namePlaceholder={namePlaceholder}\n                valuePlaceholder={valuePlaceholder}\n                className=\"opacity-80\"\n                pair={isDragging}\n                index={0}\n                stateKey={null}\n              />\n            )}\n          </DragOverlay>\n        </DndContext>\n      </div>\n\n      <div\n        // There's a weird bug where clicking below one of the above Codemirror inputs will cause\n        // it to focus. Putting this element here prevents that\n        aria-hidden\n        onClick={(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n        }}\n      />\n    </div>\n  );\n}\n\ntype PairEditorRowProps = {\n  className?: string;\n  pair: PairWithId;\n  forceFocusNamePairId?: string | null;\n  forceFocusValuePairId?: string | null;\n  onChange?: (pair: PairWithId) => void;\n  onDelete?: (pair: PairWithId, focusPrevious: boolean) => void;\n  onFocusName?: (pair: PairWithId) => void;\n  onFocusValue?: (pair: PairWithId) => void;\n  onSubmit?: (pair: PairWithId) => void;\n  isLast?: boolean;\n  disabled?: boolean;\n  disableDrag?: boolean;\n  index: number;\n  isDraggingGlobal?: boolean;\n  setRef?: (id: string, n: RowHandle | null) => void;\n} & Pick<\n  PairEditorProps,\n  | \"allowFileValues\"\n  | \"allowMultilineValues\"\n  | \"forcedEnvironmentId\"\n  | \"forceUpdateKey\"\n  | \"nameAutocomplete\"\n  | \"nameAutocompleteVariables\"\n  | \"namePlaceholder\"\n  | \"nameValidate\"\n  | \"nameAutocompleteFunctions\"\n  | \"stateKey\"\n  | \"valueAutocomplete\"\n  | \"valueAutocompleteFunctions\"\n  | \"valueAutocompleteVariables\"\n  | \"valuePlaceholder\"\n  | \"valueType\"\n  | \"valueValidate\"\n>;\n\ninterface RowHandle {\n  focusName(): void;\n  focusValue(): void;\n}\n\nexport function PairEditorRow({\n  allowFileValues,\n  allowMultilineValues,\n  className,\n  disableDrag,\n  disabled,\n  forceUpdateKey,\n  forcedEnvironmentId,\n  index,\n  isLast,\n  nameAutocomplete,\n  nameAutocompleteFunctions,\n  nameAutocompleteVariables,\n  namePlaceholder,\n  nameValidate,\n  isDraggingGlobal,\n  onChange,\n  onDelete,\n  onFocusName,\n  onFocusValue,\n  pair,\n  stateKey,\n  valueAutocomplete,\n  valueAutocompleteFunctions,\n  valueAutocompleteVariables,\n  valuePlaceholder,\n  valueType,\n  valueValidate,\n  setRef,\n}: PairEditorRowProps) {\n  const nameInputRef = useRef<InputHandle>(null);\n  const valueInputRef = useRef<InputHandle>(null);\n  const handle = useRef<RowHandle>({\n    focusName() {\n      nameInputRef.current?.focus();\n    },\n    focusValue() {\n      valueInputRef.current?.focus();\n    },\n  });\n\n  const initNameInputRef = useCallback(\n    (n: InputHandle | null) => {\n      nameInputRef.current = n;\n      if (nameInputRef.current && valueInputRef.current) {\n        setRef?.(pair.id, handle.current);\n      }\n    },\n    [pair.id, setRef],\n  );\n\n  const initValueInputRef = useCallback(\n    (n: InputHandle | null) => {\n      valueInputRef.current = n;\n      if (nameInputRef.current && valueInputRef.current) {\n        setRef?.(pair.id, handle.current);\n      }\n    },\n    [pair.id, setRef],\n  );\n\n  const handleFocusName = useCallback(() => onFocusName?.(pair), [onFocusName, pair]);\n  const handleFocusValue = useCallback(() => onFocusValue?.(pair), [onFocusValue, pair]);\n  const handleDelete = useCallback(() => onDelete?.(pair, false), [onDelete, pair]);\n\n  const handleChangeEnabled = useMemo(\n    () => (enabled: boolean) => onChange?.({ ...pair, enabled }),\n    [onChange, pair],\n  );\n\n  const handleChangeName = useMemo(\n    () => (name: string) => onChange?.({ ...pair, name }),\n    [onChange, pair],\n  );\n\n  const handleChangeValueText = useMemo(\n    () => (value: string) => onChange?.({ ...pair, value, isFile: false }),\n    [onChange, pair],\n  );\n\n  const handleChangeValueFile = useMemo(\n    () =>\n      ({ filePath }: { filePath: string | null }) =>\n        onChange?.({ ...pair, value: filePath ?? \"\", isFile: true }),\n    [onChange, pair],\n  );\n\n  const handleChangeValueContentType = useMemo(\n    () => (contentType: string) => onChange?.({ ...pair, contentType }),\n    [onChange, pair],\n  );\n\n  const handleChangeValueFilename = useMemo(\n    () => (filename: string) => onChange?.({ ...pair, filename }),\n    [onChange, pair],\n  );\n\n  const handleEditMultiLineValue = useCallback(\n    () =>\n      showDialog({\n        id: \"pair-edit-multiline\",\n        size: \"dynamic\",\n        title: <>Edit {pair.name}</>,\n        render: ({ hide }) => (\n          <MultilineEditDialog\n            hide={hide}\n            onChange={handleChangeValueText}\n            defaultValue={pair.value}\n            contentType={pair.contentType ?? null}\n          />\n        ),\n      }),\n    [handleChangeValueText, pair.contentType, pair.name, pair.value],\n  );\n\n  const defaultItems = useMemo(\n    (): DropdownItem[] => [\n      {\n        label: \"Edit Multi-line\",\n        onSelect: handleEditMultiLineValue,\n        hidden: !allowMultilineValues,\n      },\n      {\n        label: \"Delete\",\n        onSelect: handleDelete,\n        color: \"danger\",\n      },\n    ],\n    [allowMultilineValues, handleDelete, handleEditMultiLineValue],\n  );\n\n  const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: pair.id });\n  const { setNodeRef: setDroppableRef } = useDroppable({ id: pair.id });\n\n  // Filter out the current pair name\n  const valueAutocompleteVariablesFiltered = useMemo<EditorProps[\"autocompleteVariables\"]>(() => {\n    if (valueAutocompleteVariables === \"environment\") {\n      return (v: WrappedEnvironmentVariable): boolean => v.variable.name !== pair.name;\n    }\n    return valueAutocompleteVariables;\n  }, [pair.name, valueAutocompleteVariables]);\n\n  const handleSetRef = useCallback(\n    (n: HTMLDivElement | null) => {\n      setDraggableRef(n);\n      setDroppableRef(n);\n    },\n    [setDraggableRef, setDroppableRef],\n  );\n\n  return (\n    <div\n      ref={handleSetRef}\n      className={classNames(\n        className,\n        \"group/pair-row grid grid-cols-[auto_auto_minmax(0,1fr)_auto]\",\n        \"grid-rows-1 items-center\",\n        !pair.enabled && \"opacity-60\",\n      )}\n    >\n      <Checkbox\n        hideLabel\n        title={pair.enabled ? \"Disable item\" : \"Enable item\"}\n        disabled={isLast || disabled}\n        checked={isLast ? false : !!pair.enabled}\n        className={classNames(isLast && \"!opacity-disabled\")}\n        onChange={handleChangeEnabled}\n      />\n      {!isLast && !disableDrag ? (\n        <div\n          {...attributes}\n          {...listeners}\n          className={classNames(\n            \"py-2 h-7 w-4 flex items-center\",\n            \"justify-center opacity-0 group-hover/pair-row:opacity-70\",\n          )}\n        >\n          <Icon size=\"sm\" icon=\"grip_vertical\" className=\"pointer-events-none\" />\n        </div>\n      ) : (\n        <span className=\"w-4\" />\n      )}\n      <div\n        className={classNames(\n          \"grid items-center\",\n          \"@xs:gap-2 @xs:!grid-rows-1 @xs:!grid-cols-[minmax(0,1fr)_minmax(0,1fr)]\",\n          \"gap-0.5 grid-cols-1 grid-rows-2\",\n        )}\n      >\n        <Input\n          setRef={initNameInputRef}\n          hideLabel\n          stateKey={`name.${pair.id}.${stateKey}`}\n          disabled={disabled}\n          wrapLines={false}\n          readOnly={pair.readOnlyName || isDraggingGlobal}\n          size=\"sm\"\n          required={!isLast && !!pair.enabled && !!pair.value}\n          validate={nameValidate}\n          forcedEnvironmentId={forcedEnvironmentId}\n          forceUpdateKey={forceUpdateKey}\n          containerClassName={classNames(\"bg-surface\", isLast && \"border-dashed\")}\n          defaultValue={pair.name}\n          label=\"Name\"\n          name={`name[${index}]`}\n          onChange={handleChangeName}\n          onFocus={handleFocusName}\n          placeholder={namePlaceholder ?? \"name\"}\n          autocomplete={nameAutocomplete}\n          autocompleteVariables={nameAutocompleteVariables}\n          autocompleteFunctions={nameAutocompleteFunctions}\n        />\n        <div className=\"w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center\">\n          {pair.isFile ? (\n            <SelectFile\n              disabled={disabled}\n              inline\n              size=\"xs\"\n              filePath={pair.value}\n              nameOverride={pair.filename || null}\n              onChange={handleChangeValueFile}\n            />\n          ) : pair.value.includes(\"\\n\") ? (\n            <Button\n              color=\"secondary\"\n              size=\"sm\"\n              onClick={handleEditMultiLineValue}\n              title={pair.value}\n              className=\"text-xs font-mono\"\n            >\n              {pair.value.split(\"\\n\").join(\" \")}\n            </Button>\n          ) : (\n            <Input\n              setRef={initValueInputRef}\n              hideLabel\n              stateKey={`value.${pair.id}.${stateKey}`}\n              wrapLines={false}\n              size=\"sm\"\n              disabled={disabled}\n              readOnly={isDraggingGlobal}\n              containerClassName={classNames(\"bg-surface\", isLast && \"border-dashed\")}\n              validate={valueValidate}\n              forcedEnvironmentId={forcedEnvironmentId}\n              forceUpdateKey={forceUpdateKey}\n              defaultValue={pair.value}\n              label=\"Value\"\n              name={`value[${index}]`}\n              onChange={handleChangeValueText}\n              onFocus={handleFocusValue}\n              type={isLast ? \"text\" : typeof valueType === \"function\" ? valueType(pair) : valueType}\n              placeholder={valuePlaceholder ?? \"value\"}\n              autocomplete={valueAutocomplete?.(pair.name)}\n              autocompleteFunctions={valueAutocompleteFunctions}\n              autocompleteVariables={valueAutocompleteVariablesFiltered}\n            />\n          )}\n        </div>\n      </div>\n      {allowFileValues ? (\n        <FileActionsDropdown\n          pair={pair}\n          onChangeFile={handleChangeValueFile}\n          onChangeText={handleChangeValueText}\n          onChangeContentType={handleChangeValueContentType}\n          onChangeFilename={handleChangeValueFilename}\n          onDelete={handleDelete}\n          editMultiLine={handleEditMultiLineValue}\n        />\n      ) : (\n        <Dropdown items={defaultItems}>\n          <IconButton\n            iconSize=\"sm\"\n            size=\"xs\"\n            icon={isLast || disabled ? \"empty\" : \"chevron_down\"}\n            title=\"Select form data type\"\n            className=\"text-text-subtlest\"\n          />\n        </Dropdown>\n      )}\n    </div>\n  );\n}\n\nconst fileItems: RadioDropdownItem<string>[] = [\n  { label: \"Text\", value: \"text\" },\n  { label: \"File\", value: \"file\" },\n];\n\nfunction FileActionsDropdown({\n  pair,\n  onChangeFile,\n  onChangeText,\n  onChangeContentType,\n  onChangeFilename,\n  onDelete,\n  editMultiLine,\n}: {\n  pair: Pair;\n  onChangeFile: ({ filePath }: { filePath: string | null }) => void;\n  onChangeText: (text: string) => void;\n  onChangeContentType: (contentType: string) => void;\n  onChangeFilename: (filename: string) => void;\n  onDelete: () => void;\n  editMultiLine: () => void;\n}) {\n  const onChange = useCallback(\n    (v: string) => {\n      if (v === \"file\") onChangeFile({ filePath: \"\" });\n      else onChangeText(\"\");\n    },\n    [onChangeFile, onChangeText],\n  );\n\n  const itemsAfter = useMemo<DropdownItem[]>(\n    () => [\n      {\n        label: \"Edit Multi-Line\",\n        leftSlot: <Icon icon=\"file_code\" />,\n        hidden: pair.isFile,\n        onSelect: editMultiLine,\n      },\n      {\n        label: \"Set Content-Type\",\n        leftSlot: <Icon icon=\"pencil\" />,\n        onSelect: async () => {\n          const contentType = await showPrompt({\n            id: \"content-type\",\n            title: \"Override Content-Type\",\n            label: \"Content-Type\",\n            required: false,\n            placeholder: \"text/plain\",\n            defaultValue: pair.contentType ?? \"\",\n            confirmText: \"Set\",\n            description: \"Leave blank to auto-detect\",\n          });\n          if (contentType == null) return;\n          onChangeContentType(contentType);\n        },\n      },\n      {\n        label: \"Set File Name\",\n        leftSlot: <Icon icon=\"file_code\" />,\n        onSelect: async () => {\n          console.log(\"PAIR\", pair);\n          const defaultFilename = await basename(pair.value ?? \"\");\n          const filename = await showPrompt({\n            id: \"filename\",\n            title: \"Override Filename\",\n            label: \"Filename\",\n            required: false,\n            placeholder: defaultFilename ?? \"myfile.png\",\n            defaultValue: pair.filename,\n            confirmText: \"Set\",\n            description: \"Leave blank to use the name of the selected file\",\n          });\n          if (filename == null) return;\n          onChangeFilename(filename);\n        },\n      },\n      {\n        label: \"Unset File\",\n        leftSlot: <Icon icon=\"x\" />,\n        hidden: pair.isFile,\n        onSelect: async () => {\n          onChangeFile({ filePath: null });\n        },\n      },\n      {\n        label: \"Delete\",\n        onSelect: onDelete,\n        variant: \"danger\",\n        leftSlot: <Icon icon=\"trash\" />,\n        color: \"danger\",\n      },\n    ],\n    [\n      editMultiLine,\n      onChangeContentType,\n      onChangeFile,\n      onDelete,\n      pair.contentType,\n      pair.isFile,\n      onChangeFilename,\n      pair.filename,\n      pair,\n    ],\n  );\n\n  return (\n    <RadioDropdown\n      value={pair.isFile ? \"file\" : \"text\"}\n      onChange={onChange}\n      items={fileItems}\n      itemsAfter={itemsAfter}\n    >\n      <IconButton\n        iconSize=\"sm\"\n        size=\"xs\"\n        icon=\"chevron_down\"\n        title=\"Select form data type\"\n        className=\"text-text-subtlest\"\n      />\n    </RadioDropdown>\n  );\n}\n\nfunction emptyPair(): PairWithId {\n  return ensurePairId({ enabled: true, name: \"\", value: \"\" });\n}\n\nfunction isPairEmpty(pair: Pair): boolean {\n  return !pair.name && !pair.value;\n}\n\nfunction MultilineEditDialog({\n  defaultValue,\n  contentType,\n  onChange,\n  hide,\n}: {\n  defaultValue: string;\n  contentType: string | null;\n  onChange: (value: string) => void;\n  hide: () => void;\n}) {\n  const [value, setValue] = useState<string>(defaultValue);\n  const language = languageFromContentType(contentType, value);\n  return (\n    <div className=\"w-[100vw] max-w-[40rem] h-[50vh] max-h-full grid grid-rows-[minmax(0,1fr)_auto]\">\n      <Editor\n        heightMode=\"auto\"\n        defaultValue={defaultValue}\n        language={language}\n        onChange={setValue}\n        stateKey={null}\n        autocompleteFunctions\n        autocompleteVariables\n      />\n      <div>\n        <Button\n          color=\"primary\"\n          className=\"ml-auto my-2\"\n          onClick={() => {\n            onChange(value);\n            hide();\n          }}\n        >\n          Done\n        </Button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/PairEditor.util.tsx",
    "content": "import { generateId } from \"../../lib/generateId\";\nimport type { Pair, PairWithId } from \"./PairEditor\";\n\nexport function ensurePairId(p: Pair): PairWithId {\n  if (typeof p.id === \"string\") {\n    return p as PairWithId;\n  }\n  return { ...p, id: p.id ?? generateId() };\n}\n"
  },
  {
    "path": "src-web/components/core/PairOrBulkEditor.tsx",
    "content": "import classNames from \"classnames\";\nimport { useKeyValue } from \"../../hooks/useKeyValue\";\nimport { BulkPairEditor } from \"./BulkPairEditor\";\nimport { IconButton } from \"./IconButton\";\nimport type { PairEditorProps } from \"./PairEditor\";\nimport { PairEditor } from \"./PairEditor\";\n\ninterface Props extends PairEditorProps {\n  preferenceName: string;\n  forcedEnvironmentId?: string;\n}\n\nexport function PairOrBulkEditor({ preferenceName, ...props }: Props) {\n  const { value: useBulk, set: setUseBulk } = useKeyValue<boolean>({\n    namespace: \"global\",\n    key: [\"bulk_edit\", preferenceName],\n    fallback: false,\n  });\n\n  return (\n    <div className=\"relative h-full w-full group/wrapper\">\n      {useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />}\n      <div className=\"absolute right-0 bottom-0\">\n        <IconButton\n          size=\"sm\"\n          variant=\"border\"\n          title={useBulk ? \"Enable form edit\" : \"Enable bulk edit\"}\n          className={classNames(\n            \"transition-opacity opacity-0 group-hover:opacity-80 hover:!opacity-100 shadow\",\n            \"bg-surface hover:text group-hover/wrapper:opacity-100\",\n          )}\n          onClick={() => setUseBulk((b) => !b)}\n          icon={useBulk ? \"table\" : \"file_code\"}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/PillButton.tsx",
    "content": "import classNames from \"classnames\";\nimport type { ButtonProps } from \"./Button\";\nimport { Button } from \"./Button\";\n\nexport function PillButton({ className, ...props }: ButtonProps) {\n  return (\n    <Button\n      size=\"2xs\"\n      variant=\"border\"\n      className={classNames(className, \"!rounded-full mx-1 !px-3\")}\n      {...props}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/PlainInput.tsx",
    "content": "import classNames from \"classnames\";\nimport type { FocusEvent, HTMLAttributes, ReactNode } from \"react\";\nimport {\n  forwardRef,\n  useCallback,\n  useImperativeHandle,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { useRandomKey } from \"../../hooks/useRandomKey\";\nimport { useStateWithDeps } from \"../../hooks/useStateWithDeps\";\nimport { generateId } from \"../../lib/generateId\";\nimport { IconButton } from \"./IconButton\";\nimport type { InputProps } from \"./Input\";\nimport { Label } from \"./Label\";\nimport { HStack } from \"./Stacks\";\n\nexport type PlainInputProps = Omit<\n  InputProps,\n  | \"wrapLines\"\n  | \"onKeyDown\"\n  | \"type\"\n  | \"stateKey\"\n  | \"autocompleteVariables\"\n  | \"autocompleteFunctions\"\n  | \"autocomplete\"\n  | \"extraExtensions\"\n  | \"forcedEnvironmentId\"\n> &\n  Pick<HTMLAttributes<HTMLInputElement>, \"onKeyDownCapture\"> & {\n    onFocusRaw?: HTMLAttributes<HTMLInputElement>[\"onFocus\"];\n    type?: \"text\" | \"password\" | \"number\";\n    step?: number;\n    hideObscureToggle?: boolean;\n    labelRightSlot?: ReactNode;\n  };\n\nexport const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(function PlainInput(\n  {\n    autoFocus,\n    autoSelect,\n    className,\n    containerClassName,\n    defaultValue,\n    forceUpdateKey: forceUpdateKeyFromAbove,\n    help,\n    hideLabel,\n    hideObscureToggle,\n    label,\n    labelClassName,\n    labelPosition = \"top\",\n    labelRightSlot,\n    leftSlot,\n    name,\n    onBlur,\n    onChange,\n    onFocus,\n    onFocusRaw,\n    onKeyDownCapture,\n    onPaste,\n    placeholder,\n    required,\n    rightSlot,\n    size = \"md\",\n    tint,\n    type = \"text\",\n    validate,\n  },\n  ref,\n) {\n  // Track a local key for updates. If the default value is changed when the input is not in focus,\n  // regenerate this to force the field to update.\n  const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();\n  const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;\n\n  const [obscured, setObscured] = useStateWithDeps(type === \"password\", [type]);\n  const [focused, setFocused] = useState(false);\n  const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(\n    ref,\n    () => inputRef.current,\n  );\n\n  const handleFocus = useCallback(\n    (e: FocusEvent<HTMLInputElement>) => {\n      onFocusRaw?.(e);\n      setFocused(true);\n      if (autoSelect) {\n        inputRef.current?.select();\n        textareaRef.current?.select();\n      }\n      onFocus?.();\n    },\n    [autoSelect, onFocus, onFocusRaw],\n  );\n\n  const handleBlur = useCallback(() => {\n    setFocused(false);\n    onBlur?.();\n  }, [onBlur]);\n\n  // Force input to update when receiving change and not in focus\n  useLayoutEffect(() => {\n    const isFocused = document.activeElement === inputRef.current;\n    if (defaultValue != null && !isFocused) {\n      regenerateFocusedUpdateKey();\n    }\n  }, [regenerateFocusedUpdateKey, defaultValue]);\n\n  const id = useRef(`input-${generateId()}`);\n  const commonClassName = classNames(\n    className,\n    \"!bg-transparent min-w-0 w-full focus:outline-none placeholder:text-placeholder\",\n    \"px-2 text-xs font-mono cursor-text\",\n  );\n\n  const handleChange = useCallback(\n    (value: string) => {\n      onChange?.(value);\n      setHasChanged(true);\n      const isValid = (value: string) => {\n        if (required && !validateRequire(value)) return false;\n        if (typeof validate === \"boolean\") return validate;\n        if (typeof validate === \"function\" && !validate(value)) return false;\n        return true;\n      };\n      inputRef.current?.setCustomValidity(isValid(value) ? \"\" : \"Invalid value\");\n    },\n    [onChange, required, setHasChanged, validate],\n  );\n\n  const wrapperRef = useRef<HTMLDivElement>(null);\n\n  return (\n    <div\n      ref={wrapperRef}\n      className={classNames(\n        \"w-full\",\n        \"pointer-events-auto\", // Just in case we're placing in disabled parent\n        labelPosition === \"left\" && \"flex items-center gap-2\",\n        labelPosition === \"top\" && \"flex-row gap-0.5\",\n      )}\n    >\n      <Label\n        htmlFor={id.current}\n        className={labelClassName}\n        visuallyHidden={hideLabel}\n        required={required}\n        help={help}\n        rightSlot={labelRightSlot}\n      >\n        {label}\n      </Label>\n      <HStack\n        alignItems=\"stretch\"\n        className={classNames(\n          containerClassName,\n          \"x-theme-input\",\n          \"relative w-full rounded-md text\",\n          \"border\",\n          \"overflow-hidden\",\n          focused ? \"border-border-focus\" : \"border-border-subtle\",\n          hasChanged && \"has-[:invalid]:border-danger\", // For built-in HTML validation\n          size === \"md\" && \"min-h-md\",\n          size === \"sm\" && \"min-h-sm\",\n          size === \"xs\" && \"min-h-xs\",\n          size === \"2xs\" && \"min-h-2xs\",\n        )}\n      >\n        {tint != null && (\n          <div\n            aria-hidden\n            className={classNames(\n              \"absolute inset-0 opacity-5 pointer-events-none\",\n              tint === \"info\" && \"bg-info\",\n              tint === \"warning\" && \"bg-warning\",\n            )}\n          />\n        )}\n        {leftSlot}\n        <HStack\n          className={classNames(\n            \"w-full min-w-0\",\n            leftSlot ? \"pl-0.5 -ml-2\" : null,\n            rightSlot ? \"pr-0.5 -mr-2\" : null,\n          )}\n        >\n          <input\n            id={id.current}\n            ref={inputRef}\n            key={forceUpdateKey}\n            type={type === \"password\" && !obscured ? \"text\" : type}\n            name={name}\n            // oxlint-disable-next-line jsx-a11y/no-autofocus\n            autoFocus={autoFocus}\n            defaultValue={defaultValue ?? undefined}\n            autoComplete=\"off\"\n            autoCapitalize=\"off\"\n            autoCorrect=\"off\"\n            onChange={(e) => handleChange(e.target.value)}\n            onPaste={(e) => onPaste?.(e.clipboardData.getData(\"Text\"))}\n            className={classNames(commonClassName, \"h-full\")}\n            onFocus={handleFocus}\n            onBlur={handleBlur}\n            required={required}\n            placeholder={placeholder}\n            onKeyDownCapture={onKeyDownCapture}\n          />\n        </HStack>\n        {type === \"password\" && !hideObscureToggle && (\n          <IconButton\n            title={\n              obscured\n                ? `Show ${typeof label === \"string\" ? label : \"field\"}`\n                : `Obscure ${typeof label === \"string\" ? label : \"field\"}`\n            }\n            size=\"xs\"\n            className=\"mr-0.5 group/obscure !h-auto my-0.5\"\n            iconClassName=\"group-hover/obscure:text\"\n            iconSize=\"sm\"\n            icon={obscured ? \"eye\" : \"eye_closed\"}\n            onClick={() => setObscured((o) => !o)}\n          />\n        )}\n        {rightSlot}\n      </HStack>\n    </div>\n  );\n});\n\nfunction validateRequire(v: string) {\n  return v.length > 0;\n}\n"
  },
  {
    "path": "src-web/components/core/Prompt.tsx",
    "content": "import type { FormInput, JsonPrimitive } from \"@yaakapp-internal/plugins\";\nimport type { FormEvent } from \"react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { generateId } from \"../../lib/generateId\";\nimport { DynamicForm } from \"../DynamicForm\";\nimport { Button } from \"./Button\";\nimport { HStack } from \"./Stacks\";\n\nexport interface PromptProps {\n  inputs: FormInput[];\n  onCancel: () => void;\n  onResult: (value: Record<string, JsonPrimitive> | null) => void;\n  confirmText?: string;\n  cancelText?: string;\n  onValuesChange?: (values: Record<string, JsonPrimitive>) => void;\n  onInputsUpdated?: (cb: (inputs: FormInput[]) => void) => void;\n}\n\nexport function Prompt({\n  onCancel,\n  inputs: initialInputs,\n  onResult,\n  confirmText = \"Confirm\",\n  cancelText = \"Cancel\",\n  onValuesChange,\n  onInputsUpdated,\n}: PromptProps) {\n  const [value, setValue] = useState<Record<string, JsonPrimitive>>({});\n  const [inputs, setInputs] = useState<FormInput[]>(initialInputs);\n  const handleSubmit = useCallback(\n    (e: FormEvent<HTMLFormElement>) => {\n      e.preventDefault();\n      onResult(value);\n    },\n    [onResult, value],\n  );\n\n  // Register callback for external input updates (from plugin dynamic resolution)\n  useEffect(() => {\n    onInputsUpdated?.(setInputs);\n  }, [onInputsUpdated]);\n\n  // Notify of value changes for dynamic resolution\n  useEffect(() => {\n    onValuesChange?.(value);\n  }, [value, onValuesChange]);\n\n  const id = `prompt.form.${useRef(generateId()).current}`;\n\n  return (\n    <form\n      className=\"grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4\"\n      onSubmit={handleSubmit}\n    >\n      <DynamicForm inputs={inputs} onChange={setValue} data={value} stateKey={id} />\n      <HStack space={2} justifyContent=\"end\">\n        <Button onClick={onCancel} variant=\"border\" color=\"secondary\">\n          {cancelText || \"Cancel\"}\n        </Button>\n        <Button type=\"submit\" color=\"primary\">\n          {confirmText || \"Done\"}\n        </Button>\n      </HStack>\n    </form>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/RadioCards.tsx",
    "content": "import classNames from \"classnames\";\nimport type { ReactNode } from \"react\";\n\nexport interface RadioCardOption<T extends string> {\n  value: T;\n  label: ReactNode;\n  description?: ReactNode;\n}\n\nexport interface RadioCardsProps<T extends string> {\n  value: T | null;\n  onChange: (value: T) => void;\n  options: RadioCardOption<T>[];\n  name: string;\n}\n\nexport function RadioCards<T extends string>({\n  value,\n  onChange,\n  options,\n  name,\n}: RadioCardsProps<T>) {\n  return (\n    <div className=\"flex flex-col gap-2\">\n      {options.map((option) => {\n        const selected = value === option.value;\n        return (\n          <label\n            key={option.value}\n            className={classNames(\n              \"flex items-start gap-3 p-3 rounded-lg border cursor-pointer\",\n              \"transition-colors\",\n              selected ? \"border-border-focus\" : \"border-border-subtle hocus:border-text-subtlest\",\n            )}\n          >\n            <input\n              type=\"radio\"\n              name={name}\n              value={option.value}\n              checked={selected}\n              onChange={() => onChange(option.value)}\n              className=\"sr-only\"\n            />\n            <div\n              className={classNames(\n                \"mt-1 w-4 h-4 flex-shrink-0 rounded-full border\",\n                \"flex items-center justify-center\",\n                selected ? \"border-focus\" : \"border-border\",\n              )}\n            >\n              {selected && <div className=\"w-2 h-2 rounded-full bg-text\" />}\n            </div>\n            <div className=\"flex flex-col gap-0.5\">\n              <span className=\"font-semibold text-text\">{option.label}</span>\n              {option.description && (\n                <span className=\"text-sm text-text-subtle\">{option.description}</span>\n              )}\n            </div>\n          </label>\n        );\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/RadioDropdown.tsx",
    "content": "import type { ReactNode } from \"react\";\nimport { useMemo } from \"react\";\nimport type { DropdownItem, DropdownItemSeparator, DropdownProps } from \"./Dropdown\";\nimport { Dropdown } from \"./Dropdown\";\nimport { Icon } from \"./Icon\";\n\nexport type RadioDropdownItem<T = string | null> =\n  | {\n      type?: \"default\";\n      label: ReactNode;\n      shortLabel?: ReactNode;\n      value: T;\n      rightSlot?: ReactNode;\n    }\n  | DropdownItemSeparator;\n\nexport interface RadioDropdownProps<T = string | null> {\n  value: T;\n  onChange: (value: T) => void;\n  itemsBefore?: DropdownItem[];\n  items: RadioDropdownItem<T>[];\n  itemsAfter?: DropdownItem[];\n  children: DropdownProps[\"children\"];\n}\n\nexport function RadioDropdown<T = string | null>({\n  value,\n  items,\n  itemsAfter,\n  itemsBefore,\n  onChange,\n  children,\n}: RadioDropdownProps<T>) {\n  const dropdownItems = useMemo(\n    () => [\n      ...((itemsBefore\n        ? [\n            ...itemsBefore,\n            {\n              type: \"separator\",\n              hidden: itemsBefore[itemsBefore.length - 1]?.type === \"separator\",\n            },\n          ]\n        : []) as DropdownItem[]),\n      ...items.map((item) => {\n        if (item.type === \"separator\") {\n          return item;\n        }\n        return {\n          key: item.value,\n          label: item.label,\n          rightSlot: item.rightSlot,\n          onSelect: () => onChange(item.value),\n          leftSlot: <Icon icon={value === item.value ? \"check\" : \"empty\"} />,\n        } as DropdownItem;\n      }),\n      ...((itemsAfter\n        ? [{ type: \"separator\", hidden: itemsAfter[0]?.type === \"separator\" }, ...itemsAfter]\n        : []) as DropdownItem[]),\n    ],\n    [itemsBefore, items, itemsAfter, value, onChange],\n  );\n\n  return (\n    <Dropdown fullWidth items={dropdownItems}>\n      {children}\n    </Dropdown>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/SegmentedControl.tsx",
    "content": "import classNames from \"classnames\";\nimport { type ReactNode, useRef } from \"react\";\nimport { useStateWithDeps } from \"../../hooks/useStateWithDeps\";\nimport { generateId } from \"../../lib/generateId\";\nimport { Button } from \"./Button\";\nimport type { IconProps } from \"./Icon\";\nimport { IconButton, type IconButtonProps } from \"./IconButton\";\nimport { Label } from \"./Label\";\nimport { HStack } from \"./Stacks\";\n\ninterface Props<T extends string> {\n  options: { value: T; label: string; icon?: IconProps[\"icon\"] }[];\n  onChange: (value: T) => void;\n  value: T;\n  name: string;\n  size?: IconButtonProps[\"size\"];\n  label: string;\n  className?: string;\n  hideLabel?: boolean;\n  labelClassName?: string;\n  help?: ReactNode;\n}\n\nexport function SegmentedControl<T extends string>({\n  value,\n  onChange,\n  options,\n  size = \"xs\",\n  label,\n  hideLabel,\n  labelClassName,\n  help,\n  className,\n}: Props<T>) {\n  const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const id = useRef(`input-${generateId()}`);\n\n  return (\n    <div className=\"w-full grid\">\n      <Label\n        htmlFor={id.current}\n        help={help}\n        visuallyHidden={hideLabel}\n        className={classNames(labelClassName)}\n      >\n        {label}\n      </Label>\n      <HStack\n        id={id.current}\n        ref={containerRef}\n        role=\"group\"\n        dir=\"ltr\"\n        space={1}\n        className={classNames(\n          className,\n          \"bg-surface-highlight rounded-lg mb-auto mr-auto\",\n          \"transition-opacity transform-gpu p-1\",\n        )}\n        onKeyDown={(e) => {\n          const selectedIndex = options.findIndex((o) => o.value === selectedValue);\n          if (e.key === \"ArrowRight\") {\n            e.preventDefault();\n            const newIndex = Math.abs((selectedIndex + 1) % options.length);\n            if (options[newIndex]) setSelectedValue(options[newIndex].value);\n            const child = containerRef.current?.children[newIndex] as HTMLButtonElement;\n            child.focus();\n          } else if (e.key === \"ArrowLeft\") {\n            e.preventDefault();\n            const newIndex = Math.abs((selectedIndex - 1) % options.length);\n            if (options[newIndex]) setSelectedValue(options[newIndex].value);\n            const child = containerRef.current?.children[newIndex] as HTMLButtonElement;\n            child.focus();\n          }\n        }}\n      >\n        {options.map((o) => {\n          const isSelected = selectedValue === o.value;\n          const isActive = value === o.value;\n          if (o.icon == null) {\n            return (\n              <Button\n                key={o.label}\n                aria-checked={isActive}\n                size={size}\n                variant=\"solid\"\n                color={isActive ? \"secondary\" : undefined}\n                role=\"radio\"\n                tabIndex={isSelected ? 0 : -1}\n                className={classNames(\n                  isActive && \"!text-text\",\n                  \"focus:ring-1 focus:ring-border-focus\",\n                )}\n                onClick={() => onChange(o.value)}\n              >\n                {o.label}\n              </Button>\n            );\n          } else {\n            return (\n              <IconButton\n                key={o.label}\n                aria-checked={isActive}\n                size={size}\n                variant=\"solid\"\n                color={isActive ? \"secondary\" : undefined}\n                role=\"radio\"\n                tabIndex={isSelected ? 0 : -1}\n                className={classNames(\n                  isActive && \"!text-text\",\n                  \"!px-1.5 !w-auto\",\n                  \"focus:ring-border-focus\",\n                )}\n                title={o.label}\n                icon={o.icon}\n                onClick={() => onChange(o.value)}\n              />\n            );\n          }\n        })}\n      </HStack>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Select.tsx",
    "content": "import { type } from \"@tauri-apps/plugin-os\";\nimport classNames from \"classnames\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { useState } from \"react\";\nimport type { ButtonProps } from \"./Button\";\nimport { Button } from \"./Button\";\nimport { Label } from \"./Label\";\nimport type { RadioDropdownItem } from \"./RadioDropdown\";\nimport { RadioDropdown } from \"./RadioDropdown\";\nimport { HStack } from \"./Stacks\";\n\nexport interface SelectProps<T extends string> {\n  name: string;\n  label: string;\n  labelPosition?: \"top\" | \"left\";\n  labelClassName?: string;\n  hideLabel?: boolean;\n  value: T;\n  help?: ReactNode;\n  leftSlot?: ReactNode;\n  options: RadioDropdownItem<T>[];\n  onChange: (value: T) => void;\n  defaultValue?: T;\n  size?: ButtonProps[\"size\"];\n  className?: string;\n  disabled?: boolean;\n  filterable?: boolean;\n}\n\nexport function Select<T extends string>({\n  labelPosition = \"top\",\n  name,\n  help,\n  labelClassName,\n  disabled,\n  hideLabel,\n  label,\n  value,\n  options,\n  leftSlot,\n  onChange,\n  className,\n  defaultValue,\n  filterable,\n  size = \"md\",\n}: SelectProps<T>) {\n  const [focused, setFocused] = useState<boolean>(false);\n  const id = `input-${name}`;\n  const isInvalidSelection = options.find((o) => \"value\" in o && o.value === value) == null;\n\n  const handleChange = (value: T) => {\n    onChange?.(value);\n  };\n\n  return (\n    <div\n      className={classNames(\n        className,\n        \"x-theme-input\",\n        \"w-full\",\n        \"pointer-events-auto\", // Just in case we're placing in disabled parent\n        labelPosition === \"left\" && \"grid grid-cols-[auto_1fr] items-center gap-2\",\n        labelPosition === \"top\" && \"flex-row gap-0.5\",\n      )}\n    >\n      <Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}>\n        {label}\n      </Label>\n      {type() === \"macos\" && !filterable ? (\n        <HStack\n          space={2}\n          className={classNames(\n            \"w-full rounded-md text text-sm font-mono\",\n            \"pl-2\",\n            \"border\",\n            focused && !disabled ? \"border-border-focus\" : \"border-border\",\n            disabled && \"border-dotted\",\n            isInvalidSelection && \"border-danger\",\n            size === \"xs\" && \"h-xs\",\n            size === \"sm\" && \"h-sm\",\n            size === \"md\" && \"h-md\",\n          )}\n        >\n          {leftSlot && <div>{leftSlot}</div>}\n          <select\n            value={value}\n            style={selectBackgroundStyles}\n            onChange={(e) => handleChange(e.target.value as T)}\n            onFocus={() => setFocused(true)}\n            onBlur={() => setFocused(false)}\n            disabled={disabled}\n            className={classNames(\n              \"pr-7 w-full outline-none bg-transparent disabled:opacity-disabled\",\n              \"leading-[1] rounded-none\", // Center the text better vertically\n            )}\n          >\n            {isInvalidSelection && <option value={\"__NONE__\"}>-- Select an Option --</option>}\n            {options.map((o) => {\n              if (o.type === \"separator\") return null;\n              return (\n                <option key={o.value} value={o.value}>\n                  {o.label}\n                  {o.value === defaultValue && \" (default)\"}\n                </option>\n              );\n            })}\n          </select>\n        </HStack>\n      ) : (\n        // Use custom \"select\" component until Tauri can be configured to have select menus not always appear in\n        // light mode\n        <RadioDropdown value={value} onChange={handleChange} items={options}>\n          <Button\n            className=\"w-full text-sm font-mono\"\n            justify=\"start\"\n            variant=\"border\"\n            size={size}\n            leftSlot={leftSlot}\n            disabled={disabled}\n            forDropdown\n          >\n            {options.find((o) => o.type !== \"separator\" && o.value === value)?.label ?? \"--\"}\n          </Button>\n        </RadioDropdown>\n      )}\n    </div>\n  );\n}\n\nconst selectBackgroundStyles: CSSProperties = {\n  backgroundImage: `url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e\")`,\n  backgroundPosition: \"right 0.3rem center\",\n  backgroundRepeat: \"no-repeat\",\n  backgroundSize: \"1.5em 1.5em\",\n  appearance: \"none\",\n  printColorAdjust: \"exact\",\n};\n"
  },
  {
    "path": "src-web/components/core/Separator.tsx",
    "content": "import type { Color } from \"@yaakapp-internal/plugins\";\nimport classNames from \"classnames\";\nimport type { ReactNode } from \"react\";\n\ninterface Props {\n  orientation?: \"horizontal\" | \"vertical\";\n  dashed?: boolean;\n  className?: string;\n  children?: ReactNode;\n  color?: Color;\n}\n\nexport function Separator({\n  color,\n  className,\n  dashed,\n  orientation = \"horizontal\",\n  children,\n}: Props) {\n  return (\n    <div role=\"presentation\" className={classNames(className, \"flex items-center w-full\")}>\n      {children && (\n        <div className=\"text-sm text-text-subtlest mr-2 whitespace-nowrap\">{children}</div>\n      )}\n      <div\n        className={classNames(\n          \"h-0 border-t opacity-60\",\n          color == null && \"border-border\",\n          color === \"primary\" && \"border-primary\",\n          color === \"secondary\" && \"border-secondary\",\n          color === \"success\" && \"border-success\",\n          color === \"notice\" && \"border-notice\",\n          color === \"warning\" && \"border-warning\",\n          color === \"danger\" && \"border-danger\",\n          color === \"info\" && \"border-info\",\n          dashed && \"border-dashed\",\n          orientation === \"horizontal\" && \"w-full h-[1px]\",\n          orientation === \"vertical\" && \"h-full w-[1px]\",\n        )}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/SizeTag.tsx",
    "content": "import { formatSize } from \"@yaakapp-internal/lib/formatSize\";\n\ninterface Props {\n  contentLength: number;\n  contentLengthCompressed?: number | null;\n}\n\nexport function SizeTag({ contentLength, contentLengthCompressed }: Props) {\n  return (\n    <span\n      className=\"font-mono\"\n      title={\n        `${contentLength} bytes` +\n        (contentLengthCompressed ? `\\n${contentLengthCompressed} bytes compressed` : \"\")\n      }\n    >\n      {formatSize(contentLength)}\n    </span>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/SplitLayout.tsx",
    "content": "import classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { useCallback, useMemo, useRef } from \"react\";\nimport { useLocalStorage } from \"react-use\";\nimport { activeWorkspaceAtom } from \"../../hooks/useActiveWorkspace\";\nimport { useContainerSize } from \"../../hooks/useContainerQuery\";\nimport { clamp } from \"../../lib/clamp\";\nimport type { ResizeHandleEvent } from \"../ResizeHandle\";\nimport { ResizeHandle } from \"../ResizeHandle\";\n\nexport type SplitLayoutLayout = \"responsive\" | \"horizontal\" | \"vertical\";\n\nexport interface SlotProps {\n  orientation: \"horizontal\" | \"vertical\";\n  style: CSSProperties;\n}\n\ninterface Props {\n  name: string;\n  firstSlot: (props: SlotProps) => ReactNode;\n  secondSlot: null | ((props: SlotProps) => ReactNode);\n  style?: CSSProperties;\n  className?: string;\n  defaultRatio?: number;\n  minHeightPx?: number;\n  minWidthPx?: number;\n  layout?: SplitLayoutLayout;\n  resizeHandleClassName?: string;\n}\n\nconst baseProperties = { minWidth: 0 };\nconst areaL = { ...baseProperties, gridArea: \"left\" };\nconst areaR = { ...baseProperties, gridArea: \"right\" };\nconst areaD = { ...baseProperties, gridArea: \"drag\" };\n\nconst STACK_VERTICAL_WIDTH = 500;\n\nexport function SplitLayout({\n  style,\n  firstSlot,\n  secondSlot,\n  className,\n  name,\n  layout = \"responsive\",\n  resizeHandleClassName,\n  defaultRatio = 0.5,\n  minHeightPx = 10,\n  minWidthPx = 10,\n}: Props) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const activeWorkspace = useAtomValue(activeWorkspaceAtom);\n  const [widthRaw, setWidth] = useLocalStorage<number>(\n    `${name}_width::${activeWorkspace?.id ?? \"n/a\"}`,\n  );\n  const [heightRaw, setHeight] = useLocalStorage<number>(\n    `${name}_height::${activeWorkspace?.id ?? \"n/a\"}`,\n  );\n  const width = widthRaw ?? defaultRatio;\n  let height = heightRaw ?? defaultRatio;\n\n  if (!secondSlot) {\n    height = 0;\n    minHeightPx = 0;\n  }\n\n  const size = useContainerSize(containerRef);\n  const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH;\n  const vertical = layout !== \"horizontal\" && (layout === \"vertical\" || verticalBasedOnSize);\n\n  const styles = useMemo<CSSProperties>(() => {\n    return {\n      ...style,\n      gridTemplate: vertical\n        ? `\n            ' ${areaL.gridArea}' minmax(0,${1 - height}fr)\n            ' ${areaD.gridArea}' 0\n            ' ${areaR.gridArea}' minmax(${minHeightPx}px,${height}fr)\n            / 1fr            \n          `\n        : `\n            ' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)\n            / ${1 - width}fr    0                 ${width}fr           \n          `,\n    };\n  }, [style, vertical, height, minHeightPx, width]);\n\n  const handleReset = useCallback(() => {\n    if (vertical) setHeight(defaultRatio);\n    else setWidth(defaultRatio);\n  }, [vertical, setHeight, defaultRatio, setWidth]);\n\n  const handleResizeMove = useCallback(\n    (e: ResizeHandleEvent) => {\n      if (containerRef.current === null) return;\n\n      // const containerRect = containerRef.current.getBoundingClientRect();\n      const { paddingLeft, paddingRight, paddingTop, paddingBottom } = getComputedStyle(\n        containerRef.current,\n      );\n      const $c = containerRef.current;\n      const containerWidth =\n        $c.clientWidth - Number.parseFloat(paddingLeft) - Number.parseFloat(paddingRight);\n      const containerHeight =\n        $c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom);\n\n      const mouseStartX = e.xStart;\n      const mouseStartY = e.yStart;\n      const startWidth = containerWidth * width;\n      const startHeight = containerHeight * height;\n\n      if (vertical) {\n        const maxHeightPx = containerHeight - minHeightPx;\n        const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeightPx, maxHeightPx);\n        setHeight(newHeightPx / containerHeight);\n      } else {\n        const maxWidthPx = containerWidth - minWidthPx;\n        const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidthPx, maxWidthPx);\n        setWidth(newWidthPx / containerWidth);\n      }\n    },\n    [width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],\n  );\n\n  return (\n    <div\n      ref={containerRef}\n      style={styles}\n      className={classNames(className, \"grid w-full h-full overflow-hidden\")}\n    >\n      {firstSlot({ style: areaL, orientation: vertical ? \"vertical\" : \"horizontal\" })}\n      {secondSlot && (\n        <>\n          <ResizeHandle\n            style={areaD}\n            className={classNames(\n              resizeHandleClassName,\n              vertical ? \"-translate-y-1\" : \"-translate-x-1\",\n            )}\n            onResizeMove={handleResizeMove}\n            onReset={handleReset}\n            side={vertical ? \"top\" : \"left\"}\n            justify=\"center\"\n          />\n          {secondSlot({ style: areaR, orientation: vertical ? \"vertical\" : \"horizontal\" })}\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Stacks.tsx",
    "content": "import classNames from \"classnames\";\nimport type { ComponentType, ForwardedRef, HTMLAttributes, ReactNode } from \"react\";\nimport { forwardRef } from \"react\";\n\nconst gapClasses = {\n  0: \"gap-0\",\n  0.5: \"gap-0.5\",\n  1: \"gap-1\",\n  1.5: \"gap-1.5\",\n  2: \"gap-2\",\n  3: \"gap-3\",\n  4: \"gap-4\",\n  5: \"gap-5\",\n  6: \"gap-6\",\n};\n\ninterface HStackProps extends BaseStackProps {\n  children?: ReactNode;\n}\n\nexport const HStack = forwardRef(function HStack(\n  { className, space, children, alignItems = \"center\", ...props }: HStackProps,\n  // oxlint-disable-next-line no-explicit-any\n  ref: ForwardedRef<any>,\n) {\n  return (\n    <BaseStack\n      ref={ref}\n      className={classNames(className, \"flex-row\", space != null && gapClasses[space])}\n      alignItems={alignItems}\n      {...props}\n    >\n      {children}\n    </BaseStack>\n  );\n});\n\nexport type VStackProps = BaseStackProps & {\n  children: ReactNode;\n};\n\nexport const VStack = forwardRef(function VStack(\n  { className, space, children, ...props }: VStackProps,\n  // oxlint-disable-next-line no-explicit-any\n  ref: ForwardedRef<any>,\n) {\n  return (\n    <BaseStack\n      ref={ref}\n      className={classNames(className, \"flex-col\", space != null && gapClasses[space])}\n      {...props}\n    >\n      {children}\n    </BaseStack>\n  );\n});\n\ntype BaseStackProps = HTMLAttributes<HTMLElement> & {\n  as?: ComponentType | \"ul\" | \"label\" | \"form\" | \"p\";\n  space?: keyof typeof gapClasses;\n  alignItems?: \"start\" | \"center\" | \"stretch\" | \"end\";\n  justifyContent?: \"start\" | \"center\" | \"end\" | \"between\";\n  wrap?: boolean;\n};\n\nconst BaseStack = forwardRef(function BaseStack(\n  { className, alignItems, justifyContent, wrap, children, as, ...props }: BaseStackProps,\n  // oxlint-disable-next-line no-explicit-any\n  ref: ForwardedRef<any>,\n) {\n  const Component = as ?? \"div\";\n  return (\n    <Component\n      ref={ref}\n      className={classNames(\n        className,\n        \"flex\",\n        wrap && \"flex-wrap\",\n        alignItems === \"center\" && \"items-center\",\n        alignItems === \"start\" && \"items-start\",\n        alignItems === \"stretch\" && \"items-stretch\",\n        alignItems === \"end\" && \"items-end\",\n        justifyContent === \"start\" && \"justify-start\",\n        justifyContent === \"center\" && \"justify-center\",\n        justifyContent === \"end\" && \"justify-end\",\n        justifyContent === \"between\" && \"justify-between\",\n      )}\n      {...props}\n    >\n      {children}\n    </Component>\n  );\n});\n"
  },
  {
    "path": "src-web/components/core/Table.tsx",
    "content": "import classNames from \"classnames\";\nimport type { ReactNode } from \"react\";\n\nexport function Table({\n  children,\n  className,\n  scrollable,\n}: {\n  children: ReactNode;\n  className?: string;\n  scrollable?: boolean;\n}) {\n  return (\n    <div className={classNames(\"w-full\", scrollable && \"h-full overflow-y-auto\")}>\n      <table\n        className={classNames(\n          className,\n          \"w-full text-sm mb-auto min-w-full max-w-full\",\n          \"border-separate border-spacing-0\",\n          scrollable && \"[&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10\",\n        )}\n      >\n        {children}\n      </table>\n    </div>\n  );\n}\n\nexport function TableBody({ children }: { children: ReactNode }) {\n  return (\n    <tbody className=\"[&>tr:not(:last-child)>td]:border-b [&>tr:not(:last-child)>td]:border-b-surface-highlight\">\n      {children}\n    </tbody>\n  );\n}\n\nexport function TableHead({ children, className }: { children: ReactNode; className?: string }) {\n  return (\n    <thead\n      className={classNames(\n        className,\n        \"bg-surface [&_th]:border-b [&_th]:border-b-surface-highlight\",\n      )}\n    >\n      {children}\n    </thead>\n  );\n}\n\nexport function TableRow({ children }: { children: ReactNode }) {\n  return <tr>{children}</tr>;\n}\n\nexport function TableCell({\n  children,\n  className,\n  align = \"left\",\n}: {\n  children: ReactNode;\n  className?: string;\n  align?: \"left\" | \"center\" | \"right\";\n}) {\n  return (\n    <td\n      className={classNames(\n        className,\n        \"py-2 [&:not(:first-child)]:pl-4 whitespace-nowrap\",\n        align === \"left\" ? \"text-left\" : align === \"center\" ? \"text-center\" : \"text-right\",\n      )}\n    >\n      {children}\n    </td>\n  );\n}\n\nexport function TruncatedWideTableCell({\n  children,\n  className,\n}: {\n  children: ReactNode;\n  className?: string;\n}) {\n  return (\n    <TableCell className={classNames(className, \"truncate max-w-0 w-full\")}>{children}</TableCell>\n  );\n}\n\nexport function TableHeaderCell({\n  children,\n  className,\n}: {\n  children?: ReactNode;\n  className?: string;\n}) {\n  return (\n    <th\n      className={classNames(\n        className,\n        \"py-2 [&:not(:first-child)]:pl-4 text-left text-text-subtle\",\n      )}\n    >\n      {children}\n    </th>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Tabs/Tabs.tsx",
    "content": "import type { DragEndEvent, DragMoveEvent, DragStartEvent } from \"@dnd-kit/core\";\nimport {\n  closestCenter,\n  DndContext,\n  DragOverlay,\n  PointerSensor,\n  useDraggable,\n  useDroppable,\n  useSensor,\n  useSensors,\n} from \"@dnd-kit/core\";\nimport classNames from \"classnames\";\nimport type { ReactNode, Ref } from \"react\";\nimport {\n  forwardRef,\n  memo,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useKeyValue } from \"../../../hooks/useKeyValue\";\nimport { fireAndForget } from \"../../../lib/fireAndForget\";\nimport { computeSideForDragMove } from \"../../../lib/dnd\";\nimport { DropMarker } from \"../../DropMarker\";\nimport { ErrorBoundary } from \"../../ErrorBoundary\";\nimport type { ButtonProps } from \"../Button\";\nimport { Button } from \"../Button\";\nimport { Icon } from \"../Icon\";\nimport type { RadioDropdownProps } from \"../RadioDropdown\";\nimport { RadioDropdown } from \"../RadioDropdown\";\n\nexport type TabItem =\n  | {\n      value: string;\n      label: string;\n      hidden?: boolean;\n      leftSlot?: ReactNode;\n      rightSlot?: ReactNode;\n    }\n  | {\n      value: string;\n      options: Omit<RadioDropdownProps, \"children\">;\n      leftSlot?: ReactNode;\n      rightSlot?: ReactNode;\n    };\n\ninterface TabsStorage {\n  order: string[];\n  activeTabs: Record<string, string>;\n}\n\nexport interface TabsRef {\n  /** Programmatically set the active tab */\n  setActiveTab: (value: string) => void;\n}\n\ninterface Props {\n  label: string;\n  /** Default tab value. If not provided, defaults to first tab. */\n  defaultValue?: string;\n  /** Called when active tab changes */\n  onChangeValue?: (value: string) => void;\n  tabs: TabItem[];\n  tabListClassName?: string;\n  className?: string;\n  children: ReactNode;\n  addBorders?: boolean;\n  layout?: \"horizontal\" | \"vertical\";\n  /** Storage key for persisting tab order and active tab. When provided, enables drag-to-reorder and active tab persistence. */\n  storageKey?: string | string[];\n  /** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */\n  activeTabKey?: string;\n}\n\nexport const Tabs = forwardRef<TabsRef, Props>(function Tabs(\n  {\n    defaultValue,\n    onChangeValue: onChangeValueProp,\n    label,\n    children,\n    tabs: originalTabs,\n    className,\n    tabListClassName,\n    addBorders,\n    layout = \"vertical\",\n    storageKey,\n    activeTabKey,\n  }: Props,\n  forwardedRef: Ref<TabsRef>,\n) {\n  const ref = useRef<HTMLDivElement | null>(null);\n  const reorderable = !!storageKey;\n\n  // Use key-value storage for persistence if storageKey is provided\n  // Handle migration from old format (string[]) to new format (TabsStorage)\n  const { value: rawStorage, set: setStorage } = useKeyValue<TabsStorage | string[]>({\n    namespace: \"no_sync\",\n    key: storageKey ?? [\"tabs\", \"default\"],\n    fallback: { order: [], activeTabs: {} },\n  });\n\n  // Migrate old format (string[]) to new format (TabsStorage)\n  const storage: TabsStorage = Array.isArray(rawStorage)\n    ? { order: rawStorage, activeTabs: {} }\n    : (rawStorage ?? { order: [], activeTabs: {} });\n\n  const savedOrder = storage.order;\n\n  // Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab\n  const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined;\n  const [internalValue, setInternalValue] = useState<string | undefined>(undefined);\n  const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;\n\n  // Helper to normalize storage (handle migration from old format)\n  const normalizeStorage = useCallback(\n    (s: TabsStorage | string[]): TabsStorage =>\n      Array.isArray(s) ? { order: s, activeTabs: {} } : s,\n    [],\n  );\n\n  // Handle tab change - update internal state, storage if we have a key, and call prop callback\n  const onChangeValue = useCallback(\n    async (newValue: string) => {\n      setInternalValue(newValue);\n      if (storageKey && activeTabKey) {\n        await setStorage((s) => {\n          const normalized = normalizeStorage(s);\n          return {\n            ...normalized,\n            activeTabs: { ...normalized.activeTabs, [activeTabKey]: newValue },\n          };\n        });\n      }\n      onChangeValueProp?.(newValue);\n    },\n    [storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage],\n  );\n\n  // Expose imperative methods via ref\n  useImperativeHandle(\n    forwardedRef,\n    () => ({\n      setActiveTab: (value: string) => {\n        fireAndForget(onChangeValue(value));\n      },\n    }),\n    [onChangeValue],\n  );\n\n  // Helper to save order\n  const setSavedOrder = useCallback(\n    async (order: string[]) => {\n      await setStorage((s) => {\n        const normalized = normalizeStorage(s);\n        return { ...normalized, order };\n      });\n    },\n    [setStorage, normalizeStorage],\n  );\n\n  // State for ordered tabs\n  const [orderedTabs, setOrderedTabs] = useState<TabItem[]>(originalTabs);\n  const [isDragging, setIsDragging] = useState<TabItem | null>(null);\n  const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);\n\n  // Reorder tabs based on saved order when tabs or savedOrder changes\n  useEffect(() => {\n    if (!storageKey || savedOrder == null || savedOrder.length === 0) {\n      setOrderedTabs(originalTabs);\n      return;\n    }\n\n    // Create a map of tab values to tab items\n    const tabMap = new Map(originalTabs.map((tab) => [tab.value, tab]));\n\n    // Reorder based on saved order, adding any new tabs at the end\n    const reordered: TabItem[] = [];\n    const seenValues = new Set<string>();\n\n    // Add tabs in saved order\n    for (const value of savedOrder) {\n      const tab = tabMap.get(value);\n      if (tab) {\n        reordered.push(tab);\n        seenValues.add(value);\n      }\n    }\n\n    // Add any new tabs that weren't in the saved order\n    for (const tab of originalTabs) {\n      if (!seenValues.has(tab.value)) {\n        reordered.push(tab);\n      }\n    }\n\n    setOrderedTabs(reordered);\n  }, [originalTabs, savedOrder, storageKey]);\n\n  const tabs = storageKey ? orderedTabs : originalTabs;\n\n  // Update tabs when value changes\n  useEffect(() => {\n    const tabs = ref.current?.querySelectorAll<HTMLDivElement>(\"[data-tab]\");\n    for (const tab of tabs ?? []) {\n      const v = tab.getAttribute(\"data-tab\");\n      const parent = tab.closest(\".tabs-container\");\n      if (parent !== ref.current) {\n        // Tab is part of a nested tab container, so ignore it\n      } else if (v === value) {\n        tab.setAttribute(\"data-state\", \"active\");\n        tab.setAttribute(\"aria-hidden\", \"false\");\n        tab.style.display = \"block\";\n      } else {\n        tab.setAttribute(\"data-state\", \"inactive\");\n        tab.setAttribute(\"aria-hidden\", \"true\");\n        tab.style.display = \"none\";\n      }\n    }\n  }, [value]);\n\n  // Drag and drop handlers\n  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));\n\n  const onDragStart = useCallback(\n    (e: DragStartEvent) => {\n      const tab = tabs.find((t) => t.value === e.active.id);\n      setIsDragging(tab ?? null);\n    },\n    [tabs],\n  );\n\n  const onDragMove = useCallback(\n    (e: DragMoveEvent) => {\n      const overId = e.over?.id as string | undefined;\n      if (!overId) return setHoveredIndex(null);\n\n      const overTab = tabs.find((t) => t.value === overId);\n      if (overTab == null) return setHoveredIndex(null);\n\n      // For vertical layout, tabs are arranged horizontally (side-by-side)\n      const orientation = layout === \"vertical\" ? \"horizontal\" : \"vertical\";\n      const side = computeSideForDragMove(overTab.value, e, orientation);\n\n      // If computeSideForDragMove returns null (shouldn't happen but be safe), default to null\n      if (side === null) return setHoveredIndex(null);\n\n      const overIndex = tabs.findIndex((t) => t.value === overId);\n      const hoveredIndex = overIndex + (side === \"before\" ? 0 : 1);\n\n      setHoveredIndex(hoveredIndex);\n    },\n    [tabs, layout],\n  );\n\n  const onDragCancel = useCallback(() => {\n    setIsDragging(null);\n    setHoveredIndex(null);\n  }, []);\n\n  const onDragEnd = useCallback(\n    (e: DragEndEvent) => {\n      setIsDragging(null);\n      setHoveredIndex(null);\n\n      const activeId = e.active.id as string | undefined;\n      const overId = e.over?.id as string | undefined;\n      if (!activeId || !overId || activeId === overId) return;\n\n      const from = tabs.findIndex((t) => t.value === activeId);\n      const baseTo = tabs.findIndex((t) => t.value === overId);\n      const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo);\n\n      if (from !== -1 && to !== -1 && from !== to) {\n        const newTabs = [...tabs];\n        const [moved] = newTabs.splice(from, 1);\n        if (moved === undefined) return;\n        newTabs.splice(to > from ? to - 1 : to, 0, moved);\n\n        setOrderedTabs(newTabs);\n\n        // Save order to storage\n        setSavedOrder(newTabs.map((t) => t.value)).catch(console.error);\n      }\n    },\n    [tabs, hoveredIndex, setSavedOrder],\n  );\n\n  const tabButtons = useMemo(() => {\n    const items: ReactNode[] = [];\n    tabs.forEach((t, i) => {\n      if (\"hidden\" in t && t.hidden) {\n        return;\n      }\n\n      const isActive = t.value === value;\n      const showDropMarkerBefore = hoveredIndex === i;\n\n      if (showDropMarkerBefore) {\n        items.push(\n          <div\n            key={`marker-${t.value}`}\n            className={classNames(\"relative\", layout === \"vertical\" ? \"w-0\" : \"h-0\")}\n          >\n            <DropMarker orientation={layout === \"vertical\" ? \"vertical\" : \"horizontal\"} />\n          </div>,\n        );\n      }\n\n      items.push(\n        <TabButton\n          key={t.value}\n          tab={t}\n          isActive={isActive}\n          addBorders={addBorders}\n          layout={layout}\n          reorderable={reorderable}\n          isDragging={isDragging?.value === t.value}\n          onChangeValue={onChangeValue}\n        />,\n      );\n    });\n    return items;\n  }, [tabs, value, addBorders, layout, reorderable, isDragging, onChangeValue, hoveredIndex]);\n\n  const tabList = (\n    <div\n      role=\"tablist\"\n      aria-label={label}\n      className={classNames(\n        tabListClassName,\n        addBorders && layout === \"horizontal\" && \"pl-3 -ml-1\",\n        addBorders && layout === \"vertical\" && \"ml-0 mb-2\",\n        \"flex items-center hide-scrollbars\",\n        layout === \"horizontal\" && \"h-full overflow-auto p-2\",\n        layout === \"vertical\" && \"overflow-x-auto overflow-y-visible \",\n        // Give space for button focus states within overflow boundary.\n        !addBorders && layout === \"vertical\" && \"py-1 pl-3 -ml-5 pr-1\",\n      )}\n    >\n      <div\n        className={classNames(\n          layout === \"horizontal\" && \"flex flex-col w-full pb-3 mb-auto\",\n          layout === \"vertical\" && \"flex flex-row flex-shrink-0 w-full\",\n        )}\n      >\n        {tabButtons}\n        {hoveredIndex === tabs.length && (\n          <div className={classNames(\"relative\", layout === \"vertical\" ? \"w-0\" : \"h-0\")}>\n            <DropMarker orientation={layout === \"vertical\" ? \"vertical\" : \"horizontal\"} />\n          </div>\n        )}\n      </div>\n    </div>\n  );\n\n  return (\n    <div\n      ref={ref}\n      className={classNames(\n        className,\n        \"tabs-container\",\n        \"h-full grid\",\n        layout === \"horizontal\" && \"grid-rows-1 grid-cols-[auto_minmax(0,1fr)]\",\n        layout === \"vertical\" && \"grid-rows-[auto_minmax(0,1fr)] grid-cols-1\",\n      )}\n    >\n      {reorderable ? (\n        <DndContext\n          autoScroll\n          sensors={sensors}\n          onDragMove={onDragMove}\n          onDragEnd={onDragEnd}\n          onDragStart={onDragStart}\n          onDragCancel={onDragCancel}\n          collisionDetection={closestCenter}\n        >\n          {tabList}\n          <DragOverlay dropAnimation={null}>\n            {isDragging && (\n              <TabButton\n                tab={isDragging}\n                isActive={isDragging.value === value}\n                addBorders={addBorders}\n                layout={layout}\n                reorderable={false}\n                isDragging={false}\n                onChangeValue={onChangeValue}\n                overlay\n              />\n            )}\n          </DragOverlay>\n        </DndContext>\n      ) : (\n        tabList\n      )}\n      {children}\n    </div>\n  );\n});\n\ninterface TabButtonProps {\n  tab: TabItem;\n  isActive: boolean;\n  addBorders?: boolean;\n  layout: \"horizontal\" | \"vertical\";\n  reorderable: boolean;\n  isDragging: boolean;\n  onChangeValue?: (value: string) => void;\n  overlay?: boolean;\n}\n\nfunction TabButton({\n  tab,\n  isActive,\n  addBorders,\n  layout,\n  reorderable,\n  isDragging,\n  onChangeValue,\n  overlay = false,\n}: TabButtonProps) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef: setDraggableRef,\n  } = useDraggable({\n    id: tab.value,\n    disabled: !reorderable,\n    // The button inside handles focus\n    attributes: { tabIndex: -1 },\n  });\n  const { setNodeRef: setDroppableRef } = useDroppable({\n    id: tab.value,\n    disabled: !reorderable,\n  });\n\n  const handleSetWrapperRef = useCallback(\n    (n: HTMLDivElement | null) => {\n      if (reorderable) {\n        setDraggableRef(n);\n        setDroppableRef(n);\n      }\n    },\n    [reorderable, setDraggableRef, setDroppableRef],\n  );\n\n  const btnProps: Partial<ButtonProps> = {\n    color: \"custom\",\n    justify: layout === \"horizontal\" ? \"start\" : \"center\",\n    onClick: isActive\n      ? undefined\n      : (e: React.MouseEvent) => {\n          e.preventDefault(); // Prevent dropdown from opening on first click\n          onChangeValue?.(tab.value);\n        },\n    className: classNames(\n      \"flex items-center rounded whitespace-nowrap\",\n      \"!px-2 ml-[1px]\",\n      \"outline-none\",\n      \"ring-none\",\n      \"focus-visible-or-class:outline-2\",\n      addBorders && \"border focus-visible:bg-surface-highlight\",\n      isActive ? \"text-text\" : \"text-text-subtle\",\n      isActive && addBorders\n        ? \"border-surface-active bg-surface-active\"\n        : layout === \"vertical\"\n          ? \"border-border-subtle\"\n          : \"border-transparent\",\n      layout === \"horizontal\" && \"min-w-[10rem]\",\n      isDragging && \"opacity-50\",\n      overlay && \"opacity-80\",\n    ),\n  };\n\n  const buttonContent = (() => {\n    if (\"options\" in tab) {\n      const option = tab.options.items.find((i) => \"value\" in i && i.value === tab.options.value);\n      return (\n        <RadioDropdown\n          key={tab.value}\n          items={tab.options.items}\n          itemsAfter={tab.options.itemsAfter}\n          itemsBefore={tab.options.itemsBefore}\n          value={tab.options.value}\n          onChange={tab.options.onChange}\n        >\n          <Button\n            leftSlot={tab.leftSlot}\n            rightSlot={\n              <div className=\"flex items-center\">\n                {tab.rightSlot}\n                <Icon\n                  size=\"sm\"\n                  icon=\"chevron_down\"\n                  className={classNames(\n                    \"ml-1\",\n                    isActive ? \"text-text-subtle\" : \"text-text-subtlest\",\n                  )}\n                />\n              </div>\n            }\n            {...btnProps}\n          >\n            {option && \"shortLabel\" in option && option.shortLabel\n              ? option.shortLabel\n              : (option?.label ?? \"Unknown\")}\n          </Button>\n        </RadioDropdown>\n      );\n    }\n    return (\n      <Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>\n        {\"label\" in tab && tab.label ? tab.label : tab.value}\n      </Button>\n    );\n  })();\n\n  // Apply drag handlers to wrapper, not button\n  const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {};\n\n  return (\n    <div\n      ref={handleSetWrapperRef}\n      className={classNames(\"relative\", layout === \"vertical\" && \"mr-2\")}\n      {...wrapperProps}\n    >\n      {buttonContent}\n    </div>\n  );\n}\n\ninterface TabContentProps {\n  value: string;\n  children: ReactNode;\n  className?: string;\n}\n\nexport const TabContent = memo(function TabContent({\n  value,\n  children,\n  className,\n}: TabContentProps) {\n  return (\n    <ErrorBoundary name={`Tab ${value}`}>\n      <div\n        tabIndex={-1}\n        data-tab={value}\n        className={classNames(className, \"tab-content\", \"hidden w-full h-full pt-2\")}\n      >\n        {children}\n      </div>\n    </ErrorBoundary>\n  );\n});\n\n/**\n * Programmatically set the active tab for a Tabs component that uses storageKey + activeTabKey.\n * This is useful when you need to change the tab from outside the component (e.g., in response to an event).\n */\nexport async function setActiveTab({\n  storageKey,\n  activeTabKey,\n  value,\n}: {\n  storageKey: string;\n  activeTabKey: string;\n  value: string;\n}): Promise<void> {\n  const { getKeyValue, setKeyValue } = await import(\"../../../lib/keyValueStore\");\n  const current = getKeyValue<TabsStorage>({\n    namespace: \"no_sync\",\n    key: storageKey,\n    fallback: { order: [], activeTabs: {} },\n  });\n  await setKeyValue({\n    namespace: \"no_sync\",\n    key: storageKey,\n    value: {\n      ...current,\n      activeTabs: { ...current.activeTabs, [activeTabKey]: value },\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/components/core/Toast.tsx",
    "content": "import type { ShowToastRequest } from \"@yaakapp-internal/plugins\";\nimport classNames from \"classnames\";\nimport * as m from \"motion/react-m\";\nimport type { ReactNode } from \"react\";\n\nimport { useKey } from \"react-use\";\nimport type { IconProps } from \"./Icon\";\nimport { Icon } from \"./Icon\";\nimport { IconButton } from \"./IconButton\";\nimport { VStack } from \"./Stacks\";\n\nexport interface ToastProps {\n  children: ReactNode;\n  open: boolean;\n  onClose: () => void;\n  className?: string;\n  timeout: number | null;\n  action?: (args: { hide: () => void }) => ReactNode;\n  icon?: ShowToastRequest[\"icon\"] | null;\n  color?: ShowToastRequest[\"color\"];\n}\n\nconst ICONS: Record<NonNullable<ToastProps[\"color\"] | \"custom\">, IconProps[\"icon\"] | null> = {\n  custom: null,\n  danger: \"alert_triangle\",\n  info: \"info\",\n  notice: \"alert_triangle\",\n  primary: \"info\",\n  secondary: \"info\",\n  success: \"check_circle\",\n  warning: \"alert_triangle\",\n};\n\nexport function Toast({ children, open, onClose, timeout, action, icon, color }: ToastProps) {\n  useKey(\n    \"Escape\",\n    () => {\n      if (!open) return;\n      onClose();\n    },\n    {},\n    [open],\n  );\n\n  const toastIcon = icon === null ? null : (icon ?? (color && color in ICONS && ICONS[color]));\n\n  return (\n    <m.div\n      initial={{ opacity: 0, right: \"-10%\" }}\n      animate={{ opacity: 100, right: 0 }}\n      exit={{ opacity: 0, right: \"-100%\" }}\n      transition={{ duration: 0.2 }}\n      className={classNames(\"bg-surface m-2 rounded-lg\")}\n    >\n      <div\n        className={classNames(\n          `x-theme-toast x-theme-toast--${color}`,\n          \"pointer-events-auto overflow-hidden\",\n          \"relative pointer-events-auto bg-surface text-text rounded-lg\",\n          \"border border-border shadow-lg w-[25rem]\",\n        )}\n      >\n        <div className=\"pl-3 py-3 pr-10 flex items-start gap-2 w-full max-h-[11rem] overflow-auto\">\n          {toastIcon && <Icon icon={toastIcon} color={color} className=\"mt-1 flex-shrink-0\" />}\n          <VStack space={2} className=\"w-full min-w-0\">\n            <div className=\"select-auto\">{children}</div>\n            {action?.({ hide: onClose })}\n          </VStack>\n        </div>\n\n        <IconButton\n          color={color}\n          variant=\"border\"\n          className=\"opacity-60 border-0 !absolute top-2 right-2\"\n          title=\"Dismiss\"\n          icon=\"x\"\n          onClick={onClose}\n        />\n\n        {timeout != null && (\n          <div className=\"w-full absolute bottom-0 left-0 right-0\">\n            <m.div\n              className=\"bg-surface-highlight h-[3px]\"\n              initial={{ width: \"100%\" }}\n              animate={{ width: \"0%\", opacity: 0.2 }}\n              transition={{ duration: timeout / 1000, ease: \"linear\" }}\n            />\n          </div>\n        )}\n      </div>\n    </m.div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/Tooltip.tsx",
    "content": "import classNames from \"classnames\";\nimport type { CSSProperties, KeyboardEvent, ReactNode } from \"react\";\nimport { useRef, useState } from \"react\";\nimport { generateId } from \"../../lib/generateId\";\nimport { Portal } from \"../Portal\";\n\nexport interface TooltipProps {\n  children: ReactNode;\n  content: ReactNode;\n  tabIndex?: number;\n  size?: \"md\" | \"lg\";\n  className?: string;\n}\n\nconst hiddenStyles: CSSProperties = {\n  left: -99999,\n  top: -99999,\n  visibility: \"hidden\",\n  pointerEvents: \"none\",\n  opacity: 0,\n};\n\ntype TooltipPosition = \"top\" | \"bottom\";\n\ninterface TooltipOpenState {\n  styles: CSSProperties;\n  position: TooltipPosition;\n}\n\nexport function Tooltip({ children, className, content, tabIndex, size = \"md\" }: TooltipProps) {\n  const [openState, setOpenState] = useState<TooltipOpenState | null>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const tooltipRef = useRef<HTMLDivElement>(null);\n  const showTimeout = useRef<NodeJS.Timeout>(undefined);\n\n  const handleOpenImmediate = () => {\n    if (triggerRef.current == null || tooltipRef.current == null) return;\n    clearTimeout(showTimeout.current);\n    const triggerRect = triggerRef.current.getBoundingClientRect();\n    const tooltipRect = tooltipRef.current.getBoundingClientRect();\n    const viewportHeight = document.documentElement.clientHeight;\n\n    const margin = 8;\n    const spaceAbove = Math.max(0, triggerRect.top - margin);\n    const spaceBelow = Math.max(0, viewportHeight - triggerRect.bottom - margin);\n    const preferBottom = spaceAbove < tooltipRect.height + margin && spaceBelow > spaceAbove;\n    const position: TooltipPosition = preferBottom ? \"bottom\" : \"top\";\n\n    const styles: CSSProperties = {\n      left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2),\n      maxHeight: position === \"top\" ? spaceAbove : spaceBelow,\n      ...(position === \"top\"\n        ? { bottom: viewportHeight - triggerRect.top }\n        : { top: triggerRect.bottom }),\n    };\n\n    setOpenState({ styles, position });\n  };\n\n  const handleOpen = () => {\n    clearTimeout(showTimeout.current);\n    showTimeout.current = setTimeout(handleOpenImmediate, 500);\n  };\n\n  const handleClose = () => {\n    clearTimeout(showTimeout.current);\n    setOpenState(null);\n  };\n\n  const handleToggleImmediate = () => {\n    if (openState) handleClose();\n    else handleOpenImmediate();\n  };\n\n  const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {\n    if (openState && e.key === \"Escape\") {\n      e.preventDefault();\n      e.stopPropagation();\n      handleClose();\n    }\n  };\n\n  const id = useRef(`tooltip-${generateId()}`);\n\n  return (\n    <>\n      <Portal name=\"tooltip\">\n        <div\n          ref={tooltipRef}\n          style={openState?.styles ?? hiddenStyles}\n          id={id.current}\n          role=\"tooltip\"\n          aria-hidden={openState == null}\n          onMouseEnter={handleOpenImmediate}\n          onMouseLeave={handleClose}\n          className=\"p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]\"\n        >\n          <div\n            className={classNames(\n              \"bg-surface-highlight rounded-md px-3 py-2 z-50 border border-border overflow-auto\",\n              size === \"md\" && \"max-w-sm\",\n              size === \"lg\" && \"max-w-md\",\n            )}\n          >\n            {content}\n          </div>\n          <Triangle\n            className=\"text-border\"\n            position={openState?.position === \"bottom\" ? \"top\" : \"bottom\"}\n          />\n        </div>\n      </Portal>\n      {/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- Needs to be usable in other buttons */}\n      <span\n        ref={triggerRef}\n        role=\"button\"\n        aria-describedby={openState ? id.current : undefined}\n        tabIndex={tabIndex ?? -1}\n        className={classNames(className, \"flex-grow-0 flex items-center\")}\n        onClick={handleToggleImmediate}\n        onMouseEnter={handleOpen}\n        onMouseLeave={handleClose}\n        onFocus={handleOpenImmediate}\n        onBlur={handleClose}\n        onKeyDown={handleKeyDown}\n      >\n        {children}\n      </span>\n    </>\n  );\n}\n\nfunction Triangle({ className, position }: { className?: string; position: \"top\" | \"bottom\" }) {\n  const isBottom = position === \"bottom\";\n\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"0 0 30 10\"\n      preserveAspectRatio=\"none\"\n      shapeRendering=\"crispEdges\"\n      className={classNames(\n        className,\n        \"absolute z-50 left-[calc(50%-0.4rem)] h-[0.5rem] w-[0.8rem]\",\n        isBottom\n          ? \"border-t-[2px] border-surface-highlight -bottom-[calc(0.5rem-3px)] mb-2\"\n          : \"border-b-[2px] border-surface-highlight -top-[calc(0.5rem-3px)] mt-2\",\n      )}\n    >\n      <title>Triangle</title>\n      <polygon\n        className=\"fill-surface-highlight\"\n        points={isBottom ? \"0,0 30,0 15,10\" : \"0,10 30,10 15,0\"}\n      />\n      <path\n        d={isBottom ? \"M0 0 L15 9 L30 0\" : \"M0 10 L15 1 L30 10\"}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1\"\n        strokeLinejoin=\"miter\"\n        vectorEffect=\"non-scaling-stroke\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/WebsocketStatusTag.tsx",
    "content": "import type { WebsocketConnection } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\n\ninterface Props {\n  connection: WebsocketConnection;\n  className?: string;\n}\n\nexport function WebsocketStatusTag({ connection, className }: Props) {\n  const { state, error } = connection;\n\n  let label: string;\n  let colorClass = \"text-text-subtle\";\n\n  if (error) {\n    label = \"ERROR\";\n    colorClass = \"text-danger\";\n  } else if (state === \"connected\") {\n    label = \"CONNECTED\";\n    colorClass = \"text-success\";\n  } else if (state === \"closing\") {\n    label = \"CLOSING\";\n  } else if (state === \"closed\") {\n    label = \"CLOSED\";\n    colorClass = \"text-warning\";\n  } else {\n    label = \"CONNECTING\";\n  }\n\n  return <span className={classNames(className, \"font-mono\", colorClass)}>{label}</span>;\n}\n"
  },
  {
    "path": "src-web/components/core/tree/Tree.tsx",
    "content": "import type { DragEndEvent, DragMoveEvent, DragStartEvent } from \"@dnd-kit/core\";\nimport {\n  DndContext,\n  MeasuringStrategy,\n  PointerSensor,\n  pointerWithin,\n  useDroppable,\n  useSensor,\n  useSensors,\n} from \"@dnd-kit/core\";\nimport { type } from \"@tauri-apps/plugin-os\";\nimport classNames from \"classnames\";\nimport type { ComponentType, MouseEvent, ReactElement, Ref, RefAttributes } from \"react\";\nimport {\n  forwardRef,\n  memo,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useKey, useKeyPressEvent } from \"react-use\";\nimport type { HotKeyOptions, HotkeyAction } from \"../../../hooks/useHotKey\";\nimport { useHotKey } from \"../../../hooks/useHotKey\";\nimport { computeSideForDragMove } from \"../../../lib/dnd\";\nimport { jotaiStore } from \"../../../lib/jotai\";\nimport type { ContextMenuProps, DropdownItem } from \"../Dropdown\";\nimport { ContextMenu } from \"../Dropdown\";\nimport {\n  collapsedFamily,\n  draggingIdsFamily,\n  focusIdsFamily,\n  hoveredParentFamily,\n  isCollapsedFamily,\n  selectedIdsFamily,\n} from \"./atoms\";\nimport type { SelectableTreeNode, TreeNode } from \"./common\";\nimport { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from \"./common\";\nimport { TreeDragOverlay } from \"./TreeDragOverlay\";\nimport type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from \"./TreeItem\";\nimport type { TreeItemListProps } from \"./TreeItemList\";\nimport { TreeItemList } from \"./TreeItemList\";\nimport { useSelectableItems } from \"./useSelectableItems\";\n\n/** So we re-calculate after expanding a folder during drag */\nconst measuring = { droppable: { strategy: MeasuringStrategy.Always } };\n\nexport interface TreeProps<T extends { id: string }> {\n  root: TreeNode<T>;\n  treeId: string;\n  getItemKey: (item: T) => string;\n  getContextMenu?: (items: T[]) => ContextMenuProps[\"items\"] | Promise<ContextMenuProps[\"items\"]>;\n  ItemInner: ComponentType<{ treeId: string; item: T }>;\n  ItemLeftSlotInner?: ComponentType<{ treeId: string; item: T }>;\n  ItemRightSlot?: ComponentType<{ treeId: string; item: T }>;\n  className?: string;\n  onActivate?: (item: T) => void;\n  onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;\n  hotkeys?: {\n    actions: Partial<Record<HotkeyAction, { cb: (items: T[]) => void } & HotKeyOptions>>;\n  };\n  getEditOptions?: (item: T) => {\n    defaultValue: string;\n    placeholder?: string;\n    onChange: (item: T, text: string) => void;\n  };\n}\n\nexport interface TreeHandle {\n  treeId: string;\n  focus: () => boolean;\n  hasFocus: () => boolean;\n  selectItem: (id: string, focus?: boolean) => void;\n  renameItem: (id: string) => void;\n  showContextMenu: () => void;\n}\n\nfunction TreeInner<T extends { id: string }>(\n  {\n    className,\n    getContextMenu,\n    getEditOptions,\n    getItemKey,\n    hotkeys,\n    onActivate,\n    onDragEnd,\n    ItemInner,\n    ItemLeftSlotInner,\n    ItemRightSlot,\n    root,\n    treeId,\n  }: TreeProps<T>,\n  ref: Ref<TreeHandle>,\n) {\n  const treeRef = useRef<HTMLDivElement>(null);\n  const selectableItems = useSelectableItems(root);\n  const [showContextMenu, setShowContextMenu] = useState<{\n    items: DropdownItem[];\n    x: number;\n    y: number;\n  } | null>(null);\n  const treeItemRefs = useRef<Record<string, TreeItemHandle>>({});\n  const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => {\n    if (r == null) {\n      delete treeItemRefs.current[item.id];\n    } else {\n      treeItemRefs.current[item.id] = r;\n    }\n  }, []);\n\n  // Select the first item on first render\n  // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only used for initial render\n  useEffect(() => {\n    const ids = jotaiStore.get(selectedIdsFamily(treeId));\n    const fallback = selectableItems[0];\n    if (ids.length === 0 && fallback != null) {\n      jotaiStore.set(selectedIdsFamily(treeId), [fallback.node.item.id]);\n      jotaiStore.set(focusIdsFamily(treeId), {\n        anchorId: fallback.node.item.id,\n        lastId: fallback.node.item.id,\n      });\n    }\n  }, [treeId]);\n\n  const handleCloseContextMenu = useCallback(() => {\n    setShowContextMenu(null);\n  }, []);\n\n  const isTreeFocused = useCallback(() => {\n    return treeRef.current?.contains(document.activeElement);\n  }, []);\n\n  const tryFocus = useCallback(() => {\n    const $el = treeRef.current?.querySelector<HTMLButtonElement>(\n      '.tree-item button[tabindex=\"0\"]',\n    );\n    if ($el == null) {\n      return false;\n    }\n    $el.focus();\n    $el.scrollIntoView({ block: \"nearest\" });\n    return true;\n  }, []);\n\n  const ensureTabbableItem = useCallback(() => {\n    const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;\n    const lastSelectedItem = selectableItems.find(\n      (i) => i.node.item.id === lastSelectedId && !i.node.hidden,\n    );\n\n    // If no item found, default to selecting the first item (prefer leaf node);\n    if (lastSelectedItem == null) {\n      const firstLeafItem = selectableItems.find((i) => !i.node.hidden && i.node.children == null);\n      const firstItem = firstLeafItem ?? selectableItems.find((i) => !i.node.hidden);\n      if (firstItem != null) {\n        const id = firstItem.node.item.id;\n        jotaiStore.set(selectedIdsFamily(treeId), [id]);\n        jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });\n      }\n      return;\n    }\n\n    const closest = closestVisibleNode(treeId, lastSelectedItem.node);\n    if (closest != null) {\n      const id = closest.item.id;\n      jotaiStore.set(selectedIdsFamily(treeId), [id]);\n      jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });\n    }\n  }, [selectableItems, treeId]);\n\n  // Ensure there's always a tabbable item after collapsed state changes\n  useEffect(() => {\n    const unsub = jotaiStore.sub(collapsedFamily(treeId), ensureTabbableItem);\n    return unsub;\n  }, [ensureTabbableItem, treeId]);\n\n  // Ensure there's always a tabbable item after render\n  useEffect(() => {\n    requestAnimationFrame(ensureTabbableItem);\n  });\n\n  const hasFocus = useCallback(() => {\n    return treeRef.current?.contains(document.activeElement) ?? false;\n  }, []);\n\n  const setSelected = useCallback(\n    (ids: string[], focus: boolean) => {\n      jotaiStore.set(selectedIdsFamily(treeId), ids);\n      // TODO: Figure out a better way than timeout\n      if (!focus) return;\n      setTimeout(tryFocus, 50);\n    },\n    [treeId, tryFocus],\n  );\n\n  const treeHandle = useMemo<TreeHandle>(\n    () => ({\n      treeId,\n      focus: tryFocus,\n      hasFocus: hasFocus,\n      renameItem: (id) => treeItemRefs.current[id]?.rename(),\n      selectItem: (id, focus) => {\n        if (jotaiStore.get(selectedIdsFamily(treeId)).includes(id)) {\n          // Already selected\n          return;\n        }\n        jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });\n        setSelected([id], focus === true);\n      },\n      showContextMenu: async () => {\n        if (getContextMenu == null) return;\n        const items = getSelectedItems(treeId, selectableItems);\n        const menuItems = await getContextMenu(items);\n        const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;\n        const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null;\n        if (rect == null) return;\n        setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y });\n      },\n    }),\n    [getContextMenu, hasFocus, selectableItems, setSelected, treeId, tryFocus],\n  );\n\n  useImperativeHandle(ref, (): TreeHandle => treeHandle, [treeHandle]);\n\n  const handleGetContextMenu = useMemo(() => {\n    if (getContextMenu == null) return;\n    return (item: T) => {\n      const items = getSelectedItems(treeId, selectableItems);\n      const isSelected = items.find((i) => i.id === item.id);\n      if (isSelected) {\n        // If right-clicked an item that was in the multiple-selection, use the entire selection\n        return getContextMenu(items);\n      }\n      // If right-clicked an item that was NOT in the multiple-selection, just use that one\n      // Also update the selection with it\n      setSelected([item.id], false);\n      jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));\n      return getContextMenu([item]);\n    };\n  }, [getContextMenu, selectableItems, setSelected, treeId]);\n\n  const handleSelect = useCallback<NonNullable<TreeItemProps<T>[\"onClick\"]>>(\n    (item, { shiftKey, metaKey, ctrlKey }) => {\n      const anchorSelectedId = jotaiStore.get(focusIdsFamily(treeId)).anchorId;\n      const selectedIdsAtom = selectedIdsFamily(treeId);\n      const selectedIds = jotaiStore.get(selectedIdsAtom);\n\n      // Mark the item as the last one selected\n      jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));\n\n      if (shiftKey) {\n        const validSelectableItems = getValidSelectableItems(treeId, selectableItems);\n        const anchorIndex = validSelectableItems.findIndex(\n          (i) => i.node.item.id === anchorSelectedId,\n        );\n        const currIndex = validSelectableItems.findIndex((v) => v.node.item.id === item.id);\n\n        // Nothing was selected yet, so just select this item\n        if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) {\n          setSelected([item.id], true);\n          jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));\n          return;\n        }\n\n        if (currIndex > anchorIndex) {\n          // Selecting down\n          const itemsToSelect = validSelectableItems.slice(anchorIndex, currIndex + 1);\n          setSelected(\n            itemsToSelect.map((v) => v.node.item.id),\n            true,\n          );\n        } else if (currIndex < anchorIndex) {\n          // Selecting up\n          const itemsToSelect = validSelectableItems.slice(currIndex, anchorIndex + 1);\n          setSelected(\n            itemsToSelect.map((v) => v.node.item.id),\n            true,\n          );\n        } else {\n          setSelected([item.id], true);\n        }\n      } else if (type() === \"macos\" ? metaKey : ctrlKey) {\n        const withoutCurr = selectedIds.filter((id) => id !== item.id);\n        if (withoutCurr.length === selectedIds.length) {\n          // It wasn't in there, so add it\n          setSelected([...selectedIds, item.id], true);\n        } else {\n          // It was in there, so remove it\n          setSelected(withoutCurr, true);\n        }\n      } else {\n        // Select single\n        setSelected([item.id], true);\n        jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));\n      }\n    },\n    [selectableItems, setSelected, treeId],\n  );\n\n  const handleClick = useCallback<NonNullable<TreeItemProps<T>[\"onClick\"]>>(\n    (item, e) => {\n      if (e.shiftKey || e.ctrlKey || e.metaKey) {\n        handleSelect(item, e);\n      } else {\n        handleSelect(item, e);\n        onActivate?.(item);\n      }\n    },\n    [handleSelect, onActivate],\n  );\n\n  const selectPrevItem = useCallback(\n    (e: TreeItemClickEvent) => {\n      const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;\n      const validSelectableItems = getValidSelectableItems(treeId, selectableItems);\n      const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);\n      const item = validSelectableItems[index - 1];\n      if (item != null) {\n        handleSelect(item.node.item, e);\n      }\n    },\n    [handleSelect, selectableItems, treeId],\n  );\n\n  const selectNextItem = useCallback(\n    (e: TreeItemClickEvent) => {\n      const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;\n      const validSelectableItems = getValidSelectableItems(treeId, selectableItems);\n      const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);\n      const item = validSelectableItems[index + 1];\n      if (item != null) {\n        handleSelect(item.node.item, e);\n      }\n    },\n    [handleSelect, selectableItems, treeId],\n  );\n\n  const selectParentItem = useCallback(\n    (e: TreeItemClickEvent) => {\n      const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;\n      const lastSelectedItem =\n        selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ?? null;\n      if (lastSelectedItem?.parent != null) {\n        handleSelect(lastSelectedItem.parent.item, e);\n      }\n    },\n    [handleSelect, selectableItems, treeId],\n  );\n\n  useKey(\n    (e) => e.key === \"ArrowUp\" || e.key.toLowerCase() === \"k\",\n    (e) => {\n      if (!isTreeFocused()) return;\n      e.preventDefault();\n      selectPrevItem(e);\n    },\n    undefined,\n    [selectableItems, handleSelect],\n  );\n\n  useKey(\n    (e) => e.key === \"ArrowDown\" || e.key.toLowerCase() === \"j\",\n    (e) => {\n      if (!isTreeFocused()) return;\n      e.preventDefault();\n      selectNextItem(e);\n    },\n    undefined,\n    [selectableItems, handleSelect],\n  );\n\n  // If the selected item is a collapsed folder, expand it. Otherwise, select next item\n  useKey(\n    (e) => e.key === \"ArrowRight\" || e.key === \"l\",\n    (e) => {\n      if (!isTreeFocused()) return;\n      e.preventDefault();\n\n      const collapsed = jotaiStore.get(collapsedFamily(treeId));\n      const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;\n      const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);\n\n      if (\n        lastSelectedId &&\n        lastSelectedItem?.node.children != null &&\n        collapsed[lastSelectedItem.node.item.id] === true\n      ) {\n        jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), false);\n      } else {\n        selectNextItem(e);\n      }\n    },\n    undefined,\n    [selectableItems, handleSelect],\n  );\n\n  // If the selected item is in a folder, select its parent.\n  // If the selected item is an expanded folder, collapse it.\n  useKey(\n    (e) => e.key === \"ArrowLeft\" || e.key === \"h\",\n    (e) => {\n      if (!isTreeFocused()) return;\n      e.preventDefault();\n\n      const collapsed = jotaiStore.get(collapsedFamily(treeId));\n      const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;\n      const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);\n\n      if (\n        lastSelectedId &&\n        lastSelectedItem?.node.children != null &&\n        collapsed[lastSelectedItem.node.item.id] !== true\n      ) {\n        jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), true);\n      } else {\n        selectParentItem(e);\n      }\n    },\n    { options: {} },\n    [selectableItems, handleSelect],\n  );\n\n  useKeyPressEvent(\"Escape\", async () => {\n    if (!treeRef.current?.contains(document.activeElement)) return;\n    clearDragState();\n    const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;\n    if (lastSelectedId == null) return;\n    setSelected([lastSelectedId], false);\n  });\n\n  const handleDragMove = useCallback(\n    function handleDragMove(e: DragMoveEvent) {\n      const over = e.over;\n      if (!over) {\n        // Clear the drop indicator when hovering outside the tree\n        jotaiStore.set(hoveredParentFamily(treeId), {\n          parentId: null,\n          parentDepth: null,\n          childIndex: null,\n          index: null,\n        });\n        return;\n      }\n\n      // Not sure when or if this happens\n      if (e.active.rect.current.initial == null) {\n        return;\n      }\n\n      // Root is anything past the end of the list, so set it to the end\n      const hoveringRoot = over.id === root.item.id;\n      if (hoveringRoot) {\n        jotaiStore.set(hoveredParentFamily(treeId), {\n          parentId: root.item.id,\n          parentDepth: root.depth,\n          index: selectableItems.length,\n          childIndex: selectableItems.length,\n        });\n        return;\n      }\n\n      const overSelectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;\n      if (overSelectableItem == null) {\n        return;\n      }\n\n      const draggingItems = jotaiStore.get(draggingIdsFamily(treeId));\n      for (const id of draggingItems) {\n        const item = selectableItems.find((i) => i.node.item.id === id)?.node ?? null;\n        if (item == null) {\n          return;\n        }\n\n        const isSameParent = item.parent?.item.id === overSelectableItem.node.parent?.item.id;\n        if (item.localDrag && !isSameParent) {\n          return;\n        }\n      }\n\n      const node = overSelectableItem.node;\n      const side = computeSideForDragMove(node.item.id, e);\n\n      const item = node.item;\n      let hoveredParent = node.parent;\n      const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;\n      const hovered = selectableItems[dragIndex]?.node ?? null;\n      const hoveredIndex = dragIndex + (side === \"before\" ? 0 : 1);\n      let hoveredChildIndex = overSelectableItem.index + (side === \"before\" ? 0 : 1);\n\n      // Move into the folder if it's open and we're moving after it\n      if (hovered?.children != null && side === \"after\") {\n        hoveredParent = hovered;\n        hoveredChildIndex = 0;\n      }\n\n      const parentId = hoveredParent?.item.id ?? null;\n      const parentDepth = hoveredParent?.depth ?? null;\n      const index = hoveredIndex;\n      const childIndex = hoveredChildIndex;\n      const existing = jotaiStore.get(hoveredParentFamily(treeId));\n      if (\n        !(\n          parentId === existing.parentId &&\n          parentDepth === existing.parentDepth &&\n          index === existing.index &&\n          childIndex === existing.childIndex\n        )\n      ) {\n        jotaiStore.set(hoveredParentFamily(treeId), {\n          parentId,\n          parentDepth,\n          index,\n          childIndex,\n        });\n      }\n    },\n    [root.depth, root.item.id, selectableItems, treeId],\n  );\n\n  const handleDragStart = useCallback(\n    function handleDragStart(e: DragStartEvent) {\n      const selectedItems = getSelectedItems(treeId, selectableItems);\n      const isDraggingSelectedItem = selectedItems.find((i) => i.id === e.active.id);\n\n      // If we started dragging an already-selected item, we'll use that\n      if (isDraggingSelectedItem) {\n        jotaiStore.set(\n          draggingIdsFamily(treeId),\n          selectedItems.map((i) => i.id),\n        );\n      } else {\n        // If we started dragging a non-selected item, only drag that item\n        const activeItem = selectableItems.find((i) => i.node.item.id === e.active.id)?.node.item;\n        if (activeItem != null) {\n          jotaiStore.set(draggingIdsFamily(treeId), [activeItem.id]);\n          // Also update selection to just be this one\n          handleSelect(activeItem, {\n            shiftKey: false,\n            metaKey: false,\n            ctrlKey: false,\n          });\n        }\n      }\n    },\n    [handleSelect, selectableItems, treeId],\n  );\n\n  const clearDragState = useCallback(() => {\n    jotaiStore.set(hoveredParentFamily(treeId), {\n      parentId: null,\n      parentDepth: null,\n      index: null,\n      childIndex: null,\n    });\n    jotaiStore.set(draggingIdsFamily(treeId), []);\n  }, [treeId]);\n\n  const handleDragEnd = useCallback(\n    function handleDragEnd(e: DragEndEvent) {\n      // Get this from the store so our callback doesn't change all the time\n      const {\n        index: hoveredIndex,\n        parentId: hoveredParentId,\n        childIndex: hoveredChildIndex,\n      } = jotaiStore.get(hoveredParentFamily(treeId));\n      const draggingItems = jotaiStore.get(draggingIdsFamily(treeId));\n      clearDragState();\n\n      // Dropped outside the tree?\n      if (e.over == null) {\n        return;\n      }\n\n      const hoveredParentS =\n        hoveredParentId === root.item.id\n          ? { node: root, depth: 0, index: 0 }\n          : (selectableItems.find((i) => i.node.item.id === hoveredParentId) ?? null);\n      const hoveredParent = hoveredParentS?.node ?? null;\n\n      if (hoveredParent == null || hoveredIndex == null || !draggingItems?.length) {\n        return;\n      }\n\n      // Resolve the actual tree nodes for each dragged item (keeps order of draggingItems)\n      const draggedNodes: TreeNode<T>[] = draggingItems\n        .map((id) => {\n          return selectableItems.find((i) => i.node.item.id === id)?.node ?? null;\n        })\n        .filter((n) => n != null)\n        // Filter out invalid drags (dragging into descendant)\n        .filter(\n          (n) => hoveredParent.item.id !== n.item.id && !hasAncestor(hoveredParent, n.item.id),\n        );\n\n      // Work on a local copy of target children\n      const nextChildren = [...(hoveredParent.children ?? [])];\n\n      // Remove any of the dragged nodes already in the target, adjusting hoveredIndex\n      let insertAt = hoveredChildIndex ?? 0;\n      for (const node of draggedNodes) {\n        const i = nextChildren.findIndex((n) => n.item.id === node.item.id);\n        if (i !== -1) {\n          nextChildren.splice(i, 1);\n          if (i < insertAt) insertAt -= 1; // account for removed-before\n        }\n      }\n\n      // Batch callback\n      onDragEnd?.({\n        items: draggedNodes.map((n) => n.item),\n        parent: hoveredParent.item,\n        children: nextChildren.map((c) => c.item),\n        insertAt,\n      });\n    },\n    [treeId, clearDragState, selectableItems, root, onDragEnd],\n  );\n\n  const treeItemListProps: Omit<\n    TreeItemListProps<T>,\n    \"nodes\" | \"treeId\" | \"activeIdAtom\" | \"hoveredParent\" | \"hoveredIndex\"\n  > = {\n    getItemKey,\n    getContextMenu: handleGetContextMenu,\n    onClick: handleClick,\n    getEditOptions,\n    ItemInner,\n    ItemLeftSlotInner,\n    ItemRightSlot,\n  };\n\n  const handleContextMenu = useCallback(\n    async (e: MouseEvent<HTMLElement>) => {\n      if (getContextMenu == null) return;\n\n      e.preventDefault();\n      e.stopPropagation();\n      const items = await getContextMenu([]);\n      setShowContextMenu({ items, x: e.clientX, y: e.clientY });\n    },\n    [getContextMenu],\n  );\n\n  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));\n\n  return (\n    <>\n      <TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />\n      {showContextMenu && (\n        <ContextMenu\n          items={showContextMenu.items}\n          triggerPosition={showContextMenu}\n          onClose={handleCloseContextMenu}\n        />\n      )}\n      <DndContext\n        sensors={sensors}\n        collisionDetection={pointerWithin}\n        onDragStart={handleDragStart}\n        onDragEnd={handleDragEnd}\n        onDragCancel={clearDragState}\n        onDragAbort={clearDragState}\n        onDragMove={handleDragMove}\n        measuring={measuring}\n        autoScroll\n      >\n        <div\n          ref={treeRef}\n          className={classNames(\n            className,\n            \"outline-none h-full\",\n            \"overflow-y-auto overflow-x-hidden\",\n            \"grid grid-rows-[auto_1fr]\",\n          )}\n        >\n          <div\n            className={classNames(\n              \"[&_.tree-item.selected_.tree-item-inner]:text-text\",\n              \"[&:focus-within]:[&_.tree-item.selected]:bg-surface-active\",\n              \"[&:not(:focus-within)]:[&_.tree-item.selected:not([data-context-menu-open])]:bg-surface-highlight\",\n              \"[&_.tree-item.selected[data-context-menu-open]]:bg-surface-active\",\n              // Round the items, but only if the ends of the selection.\n              // Also account for the drop marker being in between items\n              \"[&_.tree-item]:rounded-md\",\n              \"[&_.tree-item.selected+.tree-item.selected]:rounded-t-none\",\n              \"[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none\",\n              \"[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none\",\n              \"[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none\",\n            )}\n          >\n            <TreeItemList\n              addTreeItemRef={handleAddTreeItemRef}\n              nodes={selectableItems}\n              treeId={treeId}\n              {...treeItemListProps}\n            />\n          </div>\n          {/* Assign root ID so we can reuse our same move/end logic */}\n          <DropRegionAfterList id={root.item.id} onContextMenu={handleContextMenu} />\n        </div>\n        <TreeDragOverlay\n          treeId={treeId}\n          selectableItems={selectableItems}\n          ItemInner={ItemInner}\n          getItemKey={getItemKey}\n        />\n      </DndContext>\n    </>\n  );\n}\n\n// 1) Preserve generics through forwardRef:\nconst Tree_ = forwardRef(TreeInner) as <T extends { id: string }>(\n  props: TreeProps<T> & RefAttributes<TreeHandle>,\n) => ReactElement | null;\n\nexport const Tree = memo(\n  Tree_,\n  ({ root: prevNode, ...prevProps }, { root: nextNode, ...nextProps }) => {\n    for (const key of Object.keys(prevProps)) {\n      if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {\n        return false;\n      }\n    }\n    return equalSubtree(prevNode, nextNode, nextProps.getItemKey);\n  },\n) as typeof Tree_;\n\nfunction DropRegionAfterList({\n  id,\n  onContextMenu,\n}: {\n  id: string;\n  onContextMenu?: (e: MouseEvent<HTMLDivElement>) => void;\n}) {\n  const { setNodeRef } = useDroppable({ id });\n  // oxlint-disable-next-line jsx-a11y/no-static-element-interactions\n  return <div ref={setNodeRef} onContextMenu={onContextMenu} />;\n}\n\ninterface TreeHotKeyProps<T extends { id: string }> {\n  action: HotkeyAction;\n  selectableItems: SelectableTreeNode<T>[];\n  treeId: string;\n  onDone: (items: T[]) => void;\n  priority?: number;\n  enable?: boolean | (() => boolean);\n}\n\nfunction TreeHotKey<T extends { id: string }>({\n  treeId,\n  action,\n  onDone,\n  selectableItems,\n  enable,\n  ...options\n}: TreeHotKeyProps<T>) {\n  useHotKey(\n    action,\n    () => {\n      onDone(getSelectedItems(treeId, selectableItems));\n    },\n    {\n      ...options,\n      enable: () => {\n        if (enable == null) return true;\n        if (typeof enable === \"function\") return enable();\n        return enable;\n      },\n    },\n  );\n  return null;\n}\n\nfunction TreeHotKeys<T extends { id: string }>({\n  treeId,\n  hotkeys,\n  selectableItems,\n}: {\n  treeId: string;\n  hotkeys: TreeProps<T>[\"hotkeys\"];\n  selectableItems: SelectableTreeNode<T>[];\n}) {\n  if (hotkeys == null) return null;\n\n  return (\n    <>\n      {Object.entries(hotkeys.actions).map(([hotkey, { cb, ...options }]) => (\n        <TreeHotKey\n          key={hotkey}\n          action={hotkey as HotkeyAction}\n          treeId={treeId}\n          onDone={cb}\n          selectableItems={selectableItems}\n          {...options}\n        />\n      ))}\n    </>\n  );\n}\n\nfunction getValidSelectableItems<T extends { id: string }>(\n  treeId: string,\n  selectableItems: SelectableTreeNode<T>[],\n) {\n  const collapsed = jotaiStore.get(collapsedFamily(treeId));\n  return selectableItems.filter((i) => {\n    if (i.node.hidden) return false;\n    let p = i.node.parent;\n    while (p) {\n      if (collapsed[p.item.id]) return false;\n      p = p.parent;\n    }\n    return true;\n  });\n}\n"
  },
  {
    "path": "src-web/components/core/tree/TreeDragOverlay.tsx",
    "content": "import { DragOverlay } from \"@dnd-kit/core\";\nimport { useAtomValue } from \"jotai\";\nimport { draggingIdsFamily } from \"./atoms\";\nimport type { SelectableTreeNode } from \"./common\";\nimport type { TreeProps } from \"./Tree\";\nimport { TreeItemList } from \"./TreeItemList\";\n\nexport function TreeDragOverlay<T extends { id: string }>({\n  treeId,\n  selectableItems,\n  getItemKey,\n  ItemInner,\n  ItemLeftSlotInner,\n}: {\n  treeId: string;\n  selectableItems: SelectableTreeNode<T>[];\n} & Pick<TreeProps<T>, \"getItemKey\" | \"ItemInner\" | \"ItemLeftSlotInner\">) {\n  const draggingItems = useAtomValue(draggingIdsFamily(treeId));\n  return (\n    <DragOverlay dropAnimation={null}>\n      <TreeItemList\n        treeId={`${treeId}.dragging`}\n        nodes={selectableItems.filter((i) => draggingItems.includes(i.node.item.id))}\n        getItemKey={getItemKey}\n        ItemInner={ItemInner}\n        ItemLeftSlotInner={ItemLeftSlotInner}\n        forceDepth={0}\n      />\n    </DragOverlay>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/tree/TreeDropMarker.tsx",
    "content": "import classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport { memo } from \"react\";\nimport { DropMarker } from \"../../DropMarker\";\nimport { hoveredParentDepthFamily, isCollapsedFamily, isIndexHoveredFamily } from \"./atoms\";\nimport type { TreeNode } from \"./common\";\n\nexport const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({\n  className,\n  treeId,\n  node,\n  index,\n}: {\n  treeId: string;\n  index: number;\n  node: TreeNode<T> | null;\n  className?: string;\n}) {\n  const itemId = node?.item.id;\n  const isHovered = useAtomValue(isIndexHoveredFamily({ treeId, index }));\n  const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));\n  const collapsed = useAtomValue(isCollapsedFamily({ treeId, itemId }));\n\n  // Only show if we're hovering over this index\n  if (!isHovered) return null;\n\n  // Don't show if we're right under a collapsed folder, or empty folder. We have a separate\n  // delayed expansion animation for that.\n  if (collapsed || node?.children?.length === 0) return null;\n\n  return (\n    <div className=\"drop-marker relative\" style={{ paddingLeft: `${parentDepth}rem` }}>\n      <DropMarker className={classNames(className)} />\n    </div>\n  );\n});\n"
  },
  {
    "path": "src-web/components/core/tree/TreeIndentGuide.tsx",
    "content": "import classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport { memo } from \"react\";\nimport { hoveredParentDepthFamily, isAncestorHoveredFamily } from \"./atoms\";\n\nexport const TreeIndentGuide = memo(function TreeIndentGuide({\n  treeId,\n  depth,\n  ancestorIds,\n}: {\n  treeId: string;\n  depth: number;\n  ancestorIds: string[];\n}) {\n  const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));\n  const isHovered = useAtomValue(isAncestorHoveredFamily({ treeId, ancestorIds }));\n\n  return (\n    <div className=\"flex\">\n      {Array.from({ length: depth }).map((_, i) => (\n        <div\n          // oxlint-disable-next-line react/no-array-index-key\n          key={i}\n          className={classNames(\n            \"w-[calc(1rem+0.5px)] border-r border-r-text-subtlest\",\n            !(parentDepth === i + 1 && isHovered) && \"opacity-30\",\n          )}\n        />\n      ))}\n    </div>\n  );\n});\n"
  },
  {
    "path": "src-web/components/core/tree/TreeItem.tsx",
    "content": "import type { DragMoveEvent } from \"@dnd-kit/core\";\nimport { useDndContext, useDndMonitor, useDraggable, useDroppable } from \"@dnd-kit/core\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport { selectAtom } from \"jotai/utils\";\nimport type {\n  MouseEvent,\n  PointerEvent,\n  FocusEvent as ReactFocusEvent,\n  KeyboardEvent as ReactKeyboardEvent,\n} from \"react\";\nimport { memo, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { computeSideForDragMove } from \"../../../lib/dnd\";\nimport { jotaiStore } from \"../../../lib/jotai\";\nimport type { ContextMenuProps, DropdownItem } from \"../Dropdown\";\nimport { ContextMenu } from \"../Dropdown\";\nimport { Icon } from \"../Icon\";\nimport { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from \"./atoms\";\nimport type { TreeNode } from \"./common\";\nimport { getNodeKey } from \"./common\";\nimport type { TreeProps } from \"./Tree\";\nimport { TreeIndentGuide } from \"./TreeIndentGuide\";\n\nexport interface TreeItemClickEvent {\n  shiftKey: boolean;\n  ctrlKey: boolean;\n  metaKey: boolean;\n}\n\nexport type TreeItemProps<T extends { id: string }> = Pick<\n  TreeProps<T>,\n  \"ItemInner\" | \"ItemLeftSlotInner\" | \"ItemRightSlot\" | \"treeId\" | \"getEditOptions\" | \"getItemKey\"\n> & {\n  node: TreeNode<T>;\n  className?: string;\n  onClick?: (item: T, e: TreeItemClickEvent) => void;\n  getContextMenu?: (item: T) => ContextMenuProps[\"items\"] | Promise<ContextMenuProps[\"items\"]>;\n  depth: number;\n  setRef?: (item: T, n: TreeItemHandle | null) => void;\n};\n\nexport interface TreeItemHandle {\n  rename: () => void;\n  isRenaming: boolean;\n  rect: () => DOMRect;\n  focus: () => void;\n  scrollIntoView: () => void;\n}\n\nconst HOVER_CLOSED_FOLDER_DELAY = 800;\n\nfunction TreeItem_<T extends { id: string }>({\n  treeId,\n  node,\n  ItemInner,\n  ItemLeftSlotInner,\n  ItemRightSlot,\n  getContextMenu,\n  onClick,\n  getEditOptions,\n  className,\n  depth,\n  setRef,\n}: TreeItemProps<T>) {\n  const listItemRef = useRef<HTMLLIElement>(null);\n  const draggableRef = useRef<HTMLButtonElement>(null);\n  const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));\n  const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));\n  const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));\n  const [editing, setEditing] = useState<boolean>(false);\n  const [dropHover, setDropHover] = useState<null | \"drop\" | \"animate\">(null);\n  const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);\n  const handle = useMemo<TreeItemHandle>(\n    () => ({\n      focus: () => {\n        draggableRef.current?.focus();\n      },\n      rename: () => {\n        if (getEditOptions != null) {\n          setEditing(true);\n        }\n      },\n      isRenaming: editing,\n      rect: () => {\n        if (listItemRef.current == null) {\n          return new DOMRect(0, 0, 0, 0);\n        }\n        return listItemRef.current.getBoundingClientRect();\n      },\n      scrollIntoView: () => {\n        listItemRef.current?.scrollIntoView({ block: \"nearest\" });\n      },\n    }),\n    [editing, getEditOptions],\n  );\n\n  useEffect(() => {\n    setRef?.(node.item, handle);\n  }, [setRef, handle, node.item]);\n\n  const ancestorIds = useMemo(() => {\n    const ids: string[] = [];\n    let p = node.parent;\n\n    while (p) {\n      ids.push(p.item.id);\n      p = p.parent;\n    }\n\n    return ids;\n  }, [node]);\n\n  const isAncestorCollapsedAtom = useMemo(\n    () =>\n      selectAtom(\n        collapsedFamily(treeId),\n        (collapsed) => ancestorIds.some((id) => collapsed[id]),\n        (a, b) => a === b,\n      ),\n    [ancestorIds, treeId],\n  );\n  const isAncestorCollapsed = useAtomValue(isAncestorCollapsedAtom);\n\n  const [showContextMenu, setShowContextMenu] = useState<{\n    items: DropdownItem[];\n    x: number;\n    y: number;\n  } | null>(null);\n\n  const handleClick = useCallback(\n    (e: MouseEvent<HTMLButtonElement>) => onClick?.(node.item, e),\n    [node, onClick],\n  );\n\n  const toggleCollapsed = useCallback(() => {\n    jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), (prev) => !prev);\n  }, [node.item.id, treeId]);\n\n  const handleSubmitNameEdit = useCallback(\n    async (el: HTMLInputElement) => {\n      getEditOptions?.(node.item).onChange(node.item, el.value);\n      onClick?.(node.item, { shiftKey: false, ctrlKey: false, metaKey: false });\n      // Slight delay for the model to propagate to the local store\n      setTimeout(() => setEditing(false), 200);\n    },\n    [getEditOptions, node.item, onClick],\n  );\n\n  const handleEditFocus = useCallback(function handleEditFocus(el: HTMLInputElement | null) {\n    el?.focus();\n    el?.select();\n  }, []);\n\n  const handleEditBlur = useCallback(\n    async function editBlur(e: ReactFocusEvent<HTMLInputElement>) {\n      await handleSubmitNameEdit(e.currentTarget);\n    },\n    [handleSubmitNameEdit],\n  );\n\n  const handleEditKeyDown = useCallback(\n    async (e: ReactKeyboardEvent<HTMLInputElement>) => {\n      e.stopPropagation(); // Don't trigger other tree keys (like arrows)\n      switch (e.key) {\n        case \"Enter\":\n          if (editing) {\n            e.preventDefault();\n            await handleSubmitNameEdit(e.currentTarget);\n          }\n          break;\n        case \"Escape\":\n          if (editing) {\n            e.preventDefault();\n            setEditing(false);\n          }\n          break;\n      }\n    },\n    [editing, handleSubmitNameEdit],\n  );\n\n  const handleDoubleClick = useCallback(() => {\n    const isFolder = node.children != null;\n    if (isFolder) {\n      toggleCollapsed();\n    } else if (getEditOptions != null) {\n      setEditing(true);\n    }\n  }, [getEditOptions, node.children, toggleCollapsed]);\n\n  const clearDropHover = () => {\n    if (startedHoverTimeout.current) {\n      clearTimeout(startedHoverTimeout.current);\n      startedHoverTimeout.current = undefined;\n    }\n    setDropHover(null);\n  };\n\n  const dndContext = useDndContext();\n\n  // Toggle auto-expand of folders when hovering over them\n  useDndMonitor({\n    onDragEnd() {\n      clearDropHover();\n    },\n    onDragMove(e: DragMoveEvent) {\n      const side = computeSideForDragMove(node.item.id, e);\n      const isFolder = node.children != null;\n      const hasChildren = (node.children?.length ?? 0) > 0;\n      const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id }));\n      if (isCollapsed && isFolder && hasChildren && side === \"after\") {\n        setDropHover(\"animate\");\n        clearTimeout(startedHoverTimeout.current);\n        startedHoverTimeout.current = setTimeout(() => {\n          jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), false);\n          clearDropHover();\n          // Force re-measure everything because all containers below the folder have been pushed down\n          requestAnimationFrame(() => {\n            dndContext.measureDroppableContainers(\n              dndContext.droppableContainers.toArray().map((c) => c.id),\n            );\n          });\n        }, HOVER_CLOSED_FOLDER_DELAY);\n      } else if (isFolder && !hasChildren && side === \"after\") {\n        setDropHover(\"drop\");\n      } else {\n        clearDropHover();\n      }\n    },\n  });\n\n  const handleContextMenu = useCallback(\n    async (e: MouseEvent<HTMLElement>) => {\n      if (getContextMenu == null) return;\n\n      e.preventDefault();\n      e.stopPropagation();\n\n      // Set data attribute on the list item to preserve active state\n      if (listItemRef.current) {\n        listItemRef.current.setAttribute(\"data-context-menu-open\", \"true\");\n      }\n\n      const items = await getContextMenu(node.item);\n      setShowContextMenu({ items, x: e.clientX ?? 100, y: e.clientY ?? 100 });\n    },\n    [getContextMenu, node.item],\n  );\n\n  const handleCloseContextMenu = useCallback(() => {\n    // Remove data attribute when context menu closes\n    if (listItemRef.current) {\n      listItemRef.current.removeAttribute(\"data-context-menu-open\");\n    }\n    setShowContextMenu(null);\n  }, []);\n\n  const {\n    attributes,\n    listeners,\n    setNodeRef: setDraggableRef,\n  } = useDraggable({ id: node.item.id, disabled: node.draggable === false || editing });\n\n  const { setNodeRef: setDroppableRef } = useDroppable({ id: node.item.id });\n\n  const handlePointerDown = useCallback(\n    function handlePointerDown(e: PointerEvent<HTMLButtonElement>) {\n      const handleByTree = e.metaKey || e.ctrlKey || e.shiftKey;\n      if (!handleByTree) {\n        listeners?.onPointerDown?.(e);\n      }\n    },\n    [listeners],\n  );\n\n  const handleSetDraggableRef = useCallback(\n    (node: HTMLButtonElement | null) => {\n      draggableRef.current = node;\n      setDraggableRef(node);\n      setDroppableRef(node);\n    },\n    [setDraggableRef, setDroppableRef],\n  );\n\n  if (node.hidden || isAncestorCollapsed) return null;\n\n  return (\n    <li\n      ref={listItemRef}\n      onContextMenu={handleContextMenu}\n      className={classNames(\n        className,\n        \"tree-item\",\n        \"h-sm\",\n        \"grid grid-cols-[auto_minmax(0,1fr)]\",\n        editing && \"ring-1 focus-within:ring-focus\",\n        dropHover != null && \"relative z-10 ring-2 ring-primary\",\n        dropHover === \"animate\" && \"animate-blinkRing\",\n        isSelected && \"selected\",\n      )}\n    >\n      <TreeIndentGuide treeId={treeId} depth={depth} ancestorIds={ancestorIds} />\n      <div\n        className={classNames(\n          \"text-text-subtle\",\n          \"grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-2 items-center rounded-md\",\n        )}\n      >\n        {showContextMenu && (\n          <ContextMenu\n            items={showContextMenu.items}\n            triggerPosition={showContextMenu}\n            onClose={handleCloseContextMenu}\n          />\n        )}\n        {node.children != null ? (\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            className=\"h-full pl-[0.5rem] outline-none\"\n            onClick={toggleCollapsed}\n          >\n            <Icon\n              icon={node.children.length === 0 ? \"dot\" : \"chevron_right\"}\n              className={classNames(\n                \"transition-transform text-text-subtlest\",\n                \"ml-auto\",\n                \"w-[1rem] h-[1rem]\",\n                !isCollapsed && node.children.length > 0 && \"rotate-90\",\n              )}\n            />\n          </button>\n        ) : (\n          <span aria-hidden /> // Make the grid happy\n        )}\n\n        <button\n          ref={handleSetDraggableRef}\n          onPointerDown={handlePointerDown}\n          onClick={handleClick}\n          onDoubleClick={handleDoubleClick}\n          disabled={editing}\n          className=\"cursor-default tree-item-inner pr-1 focus:outline-none flex items-center gap-2 h-full whitespace-nowrap\"\n          {...attributes}\n          {...listeners}\n          tabIndex={isLastSelected ? 0 : -1}\n        >\n          {ItemLeftSlotInner != null && <ItemLeftSlotInner treeId={treeId} item={node.item} />}\n          {getEditOptions != null && editing ? (\n            (() => {\n              const { defaultValue, placeholder } = getEditOptions(node.item);\n              return (\n                <input\n                  data-disable-hotkey\n                  ref={handleEditFocus}\n                  defaultValue={defaultValue}\n                  placeholder={placeholder}\n                  className=\"bg-transparent outline-none w-full cursor-text\"\n                  onBlur={handleEditBlur}\n                  onKeyDown={handleEditKeyDown}\n                />\n              );\n            })()\n          ) : (\n            <ItemInner treeId={treeId} item={node.item} />\n          )}\n        </button>\n        {ItemRightSlot != null ? (\n          <ItemRightSlot treeId={treeId} item={node.item} />\n        ) : (\n          <span aria-hidden />\n        )}\n      </div>\n    </li>\n  );\n}\n\nexport const TreeItem = memo(\n  TreeItem_,\n  ({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => {\n    const nonEqualKeys = [];\n    for (const key of Object.keys(prevProps)) {\n      if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {\n        nonEqualKeys.push(key);\n      }\n    }\n    if (nonEqualKeys.length > 0) {\n      return false;\n    }\n\n    return (\n      getNodeKey(prevNode, prevProps.getItemKey) === getNodeKey(nextNode, nextProps.getItemKey)\n    );\n  },\n) as typeof TreeItem_;\n"
  },
  {
    "path": "src-web/components/core/tree/TreeItemList.tsx",
    "content": "import type { CSSProperties } from \"react\";\nimport { Fragment } from \"react\";\nimport type { SelectableTreeNode } from \"./common\";\nimport type { TreeProps } from \"./Tree\";\nimport { TreeDropMarker } from \"./TreeDropMarker\";\nimport type { TreeItemHandle, TreeItemProps } from \"./TreeItem\";\nimport { TreeItem } from \"./TreeItem\";\n\nexport type TreeItemListProps<T extends { id: string }> = Pick<\n  TreeProps<T>,\n  \"ItemInner\" | \"ItemLeftSlotInner\" | \"ItemRightSlot\" | \"treeId\" | \"getItemKey\" | \"getEditOptions\"\n> &\n  Pick<TreeItemProps<T>, \"onClick\" | \"getContextMenu\"> & {\n    nodes: SelectableTreeNode<T>[];\n    style?: CSSProperties;\n    className?: string;\n    forceDepth?: number;\n    addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;\n  };\n\nexport function TreeItemList<T extends { id: string }>({\n  className,\n  getItemKey,\n  nodes,\n  style,\n  treeId,\n  forceDepth,\n  addTreeItemRef,\n  ...props\n}: TreeItemListProps<T>) {\n  return (\n    <ul style={style} className={className}>\n      <TreeDropMarker node={null} treeId={treeId} index={0} />\n      {nodes.map((child, i) => (\n        <Fragment key={getItemKey(child.node.item)}>\n          <TreeItem\n            treeId={treeId}\n            setRef={addTreeItemRef}\n            node={child.node}\n            getItemKey={getItemKey}\n            depth={forceDepth == null ? child.depth : forceDepth}\n            {...props}\n          />\n          <TreeDropMarker node={child.node} treeId={treeId} index={i + 1} />\n        </Fragment>\n      ))}\n    </ul>\n  );\n}\n"
  },
  {
    "path": "src-web/components/core/tree/atoms.ts",
    "content": "import { atom } from \"jotai\";\nimport { atomFamily, selectAtom } from \"jotai/utils\";\nimport { atomWithKVStorage } from \"../../../lib/atoms/atomWithKVStorage\";\n\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nexport const selectedIdsFamily = atomFamily((_treeId: string) => {\n  return atom<string[]>([]);\n});\n\nexport const isSelectedFamily = atomFamily(\n  ({ treeId, itemId }: { treeId: string; itemId: string }) => {\n    return selectAtom(selectedIdsFamily(treeId), (ids) => ids.includes(itemId), Object.is);\n  },\n  (a, b) => a.treeId === b.treeId && a.itemId === b.itemId,\n);\n\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nexport const focusIdsFamily = atomFamily((_treeId: string) => {\n  return atom<{ lastId: string | null; anchorId: string | null }>({ lastId: null, anchorId: null });\n});\n\nexport const isLastFocusedFamily = atomFamily(\n  ({ treeId, itemId }: { treeId: string; itemId: string }) =>\n    selectAtom(focusIdsFamily(treeId), (v) => v.lastId === itemId, Object.is),\n  (a, b) => a.treeId === b.treeId && a.itemId === b.itemId,\n);\n\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nexport const draggingIdsFamily = atomFamily((_treeId: string) => {\n  return atom<string[]>([]);\n});\n\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nexport const hoveredParentFamily = atomFamily((_treeId: string) => {\n  return atom<{\n    index: number | null;\n    childIndex: number | null;\n    parentId: string | null;\n    parentDepth: number | null;\n  }>({\n    index: null,\n    childIndex: null,\n    parentId: null,\n    parentDepth: null,\n  });\n});\n\nexport const isParentHoveredFamily = atomFamily(\n  ({ treeId, parentId }: { treeId: string; parentId: string | null }) =>\n    selectAtom(hoveredParentFamily(treeId), (v) => v.parentId === parentId, Object.is),\n  (a, b) => a.treeId === b.treeId && a.parentId === b.parentId,\n);\n\nexport const isAncestorHoveredFamily = atomFamily(\n  ({ treeId, ancestorIds }: { treeId: string; ancestorIds: string[] }) =>\n    selectAtom(\n      hoveredParentFamily(treeId),\n      (v) => v.parentId && ancestorIds.includes(v.parentId),\n      Object.is,\n    ),\n  (a, b) => a.treeId === b.treeId && a.ancestorIds.join(\",\") === b.ancestorIds.join(\",\"),\n);\n\nexport const isIndexHoveredFamily = atomFamily(\n  ({ treeId, index }: { treeId: string; index: number }) =>\n    selectAtom(hoveredParentFamily(treeId), (v) => v.index === index, Object.is),\n  (a, b) => a.treeId === b.treeId && a.index === b.index,\n);\n\nexport const hoveredParentDepthFamily = atomFamily((treeId: string) =>\n  selectAtom(\n    hoveredParentFamily(treeId),\n    (s) => s.parentDepth,\n    (a, b) => Object.is(a, b), // prevents re-render unless the value changes\n  ),\n);\n\nexport const collapsedFamily = atomFamily((workspaceId: string) => {\n  const key = [\"sidebar_collapsed\", workspaceId ?? \"n/a\"];\n  return atomWithKVStorage<Record<string, boolean>>(key, {});\n});\n\nexport const isCollapsedFamily = atomFamily(\n  ({ treeId, itemId = \"n/a\" }: { treeId: string; itemId: string | undefined }) =>\n    atom(\n      // --- getter ---\n      (get) => !!get(collapsedFamily(treeId))[itemId],\n\n      // --- setter ---\n      (get, set, next: boolean | ((prev: boolean) => boolean)) => {\n        const a = collapsedFamily(treeId);\n        const prevMap = get(a);\n        const prevValue = !!prevMap[itemId];\n        const value = typeof next === \"function\" ? next(prevValue) : next;\n\n        if (value === prevValue) return; // no-op\n\n        set(a, { ...prevMap, [itemId]: value });\n      },\n    ),\n  (a, b) => a.treeId === b.treeId && a.itemId === b.itemId,\n);\n"
  },
  {
    "path": "src-web/components/core/tree/common.ts",
    "content": "import { jotaiStore } from \"../../../lib/jotai\";\nimport { collapsedFamily, selectedIdsFamily } from \"./atoms\";\n\nexport interface TreeNode<T extends { id: string }> {\n  children?: TreeNode<T>[];\n  item: T;\n  hidden?: boolean;\n  parent: TreeNode<T> | null;\n  depth: number;\n  draggable?: boolean;\n  localDrag?: boolean;\n}\n\nexport interface SelectableTreeNode<T extends { id: string }> {\n  node: TreeNode<T>;\n  depth: number;\n  index: number;\n}\n\nexport function getSelectedItems<T extends { id: string }>(\n  treeId: string,\n  selectableItems: SelectableTreeNode<T>[],\n) {\n  const selectedItemIds = jotaiStore.get(selectedIdsFamily(treeId));\n  return selectableItems\n    .filter((i) => selectedItemIds.includes(i.node.item.id))\n    .map((i) => i.node.item);\n}\n\nexport function equalSubtree<T extends { id: string }>(\n  a: TreeNode<T>,\n  b: TreeNode<T>,\n  getItemKey: (t: T) => string,\n): boolean {\n  if (getNodeKey(a, getItemKey) !== getNodeKey(b, getItemKey)) return false;\n  const ak = a.children ?? [];\n  const bk = b.children ?? [];\n\n  if (ak.length !== bk.length) {\n    return false;\n  }\n\n  for (let i = 0; i < ak.length; i++) {\n    // oxlint-disable-next-line no-non-null-assertion\n    if (!equalSubtree(ak[i]!, bk[i]!, getItemKey)) return false;\n  }\n\n  return true;\n}\n\nexport function getNodeKey<T extends { id: string }>(a: TreeNode<T>, getItemKey: (i: T) => string) {\n  return getItemKey(a.item) + a.hidden;\n}\n\nexport function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {\n  if (node.parent == null) return false;\n  if (node.parent.item.id === ancestorId) return true;\n\n  // Check parents recursively\n  return hasAncestor(node.parent, ancestorId);\n}\n\nexport function isVisibleNode<T extends { id: string }>(treeId: string, node: TreeNode<T>) {\n  const collapsed = jotaiStore.get(collapsedFamily(treeId));\n  let p = node.parent;\n  while (p) {\n    if (collapsed[p.item.id]) return false; // any collapsed ancestor hides this node\n    p = p.parent;\n  }\n  return true;\n}\n\nexport function closestVisibleNode<T extends { id: string }>(\n  treeId: string,\n  node: TreeNode<T>,\n): TreeNode<T> | null {\n  let n: TreeNode<T> | null = node;\n  while (n) {\n    if (isVisibleNode(treeId, n) && !n.hidden) return n;\n    if (n.parent == null) return null;\n    n = n.parent;\n  }\n  return null;\n}\n"
  },
  {
    "path": "src-web/components/core/tree/useSelectableItems.ts",
    "content": "import { useMemo } from \"react\";\nimport type { SelectableTreeNode, TreeNode } from \"./common\";\n\nexport function useSelectableItems<T extends { id: string }>(root: TreeNode<T>) {\n  return useMemo(() => {\n    const selectableItems: SelectableTreeNode<T>[] = [];\n\n    // Put requests and folders into a tree structure\n    const next = (node: TreeNode<T>, depth = 0) => {\n      if (node.children == null) {\n        return;\n      }\n\n      // Recurse to children\n      let selectableIndex = 0;\n      for (const child of node.children) {\n        selectableItems.push({\n          node: child,\n          index: selectableIndex++,\n          depth,\n        });\n\n        next(child, depth + 1);\n      }\n    };\n\n    next(root);\n    return selectableItems;\n  }, [root]);\n}\n"
  },
  {
    "path": "src-web/components/git/BranchSelectionDialog.tsx",
    "content": "import { useState } from \"react\";\nimport { Button } from \"../core/Button\";\nimport { Select } from \"../core/Select\";\nimport { HStack, VStack } from \"../core/Stacks\";\n\ninterface Props {\n  branches: string[];\n  onCancel: () => void;\n  onSelect: (branch: string) => void;\n  selectText: string;\n}\n\nexport function BranchSelectionDialog({ branches, onCancel, onSelect, selectText }: Props) {\n  const [branch, setBranch] = useState<string>(\"__NONE__\");\n  return (\n    <VStack\n      className=\"mb-4\"\n      as=\"form\"\n      space={4}\n      onSubmit={(e) => {\n        e.preventDefault();\n        onSelect(branch);\n      }}\n    >\n      <Select\n        name=\"branch\"\n        hideLabel\n        label=\"Branch\"\n        value={branch}\n        options={branches.map((b) => ({ label: b, value: b }))}\n        onChange={setBranch}\n      />\n      <HStack space={2} justifyContent=\"end\">\n        <Button onClick={onCancel} variant=\"border\" color=\"secondary\">\n          Cancel\n        </Button>\n        <Button type=\"submit\" color=\"primary\">\n          {selectText}\n        </Button>\n      </HStack>\n    </VStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/git/GitCommitDialog.tsx",
    "content": "import type { GitStatusEntry } from \"@yaakapp-internal/git\";\nimport { useGit } from \"@yaakapp-internal/git\";\nimport type {\n  Environment,\n  Folder,\n  GrpcRequest,\n  HttpRequest,\n  WebsocketRequest,\n  Workspace,\n} from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { useCallback, useMemo, useState } from \"react\";\nimport { modelToYaml } from \"../../lib/diffYaml\";\nimport { resolvedModelName } from \"../../lib/resolvedModelName\";\nimport { showErrorToast } from \"../../lib/toast\";\nimport { Banner } from \"../core/Banner\";\nimport { Button } from \"../core/Button\";\nimport type { CheckboxProps } from \"../core/Checkbox\";\nimport { Checkbox } from \"../core/Checkbox\";\nimport { DiffViewer } from \"../core/Editor/DiffViewer\";\nimport { Icon } from \"../core/Icon\";\nimport { InlineCode } from \"../core/InlineCode\";\nimport { Input } from \"../core/Input\";\nimport { Separator } from \"../core/Separator\";\nimport { SplitLayout } from \"../core/SplitLayout\";\nimport { HStack } from \"../core/Stacks\";\nimport { EmptyStateText } from \"../EmptyStateText\";\nimport { gitCallbacks } from \"./callbacks\";\nimport { handlePushResult } from \"./git-util\";\n\ninterface Props {\n  syncDir: string;\n  onDone: () => void;\n  workspace: Workspace;\n}\n\ninterface CommitTreeNode {\n  model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Environment | Workspace;\n  status: GitStatusEntry;\n  children: CommitTreeNode[];\n  ancestors: CommitTreeNode[];\n}\n\nexport function GitCommitDialog({ syncDir, onDone, workspace }: Props) {\n  const [{ status }, { commit, commitAndPush, add, unstage }] = useGit(\n    syncDir,\n    gitCallbacks(syncDir),\n  );\n  const [isPushing, setIsPushing] = useState(false);\n  const [commitError, setCommitError] = useState<string | null>(null);\n  const [message, setMessage] = useState<string>(\"\");\n  const [selectedEntry, setSelectedEntry] = useState<GitStatusEntry | null>(null);\n\n  const handleCreateCommit = async () => {\n    setCommitError(null);\n    try {\n      await commit.mutateAsync({ message });\n      onDone();\n    } catch (err) {\n      setCommitError(String(err));\n    }\n  };\n\n  const handleCreateCommitAndPush = async () => {\n    setIsPushing(true);\n    try {\n      const r = await commitAndPush.mutateAsync({ message });\n      handlePushResult(r);\n      onDone();\n    } catch (err) {\n      showErrorToast({\n        id: \"git-commit-and-push-error\",\n        title: \"Error committing and pushing\",\n        message: String(err),\n      });\n    } finally {\n      setIsPushing(false);\n    }\n  };\n\n  const { internalEntries, externalEntries, allEntries } = useMemo(() => {\n    const allEntries = [];\n    const yaakEntries = [];\n    const externalEntries = [];\n\n    for (const entry of status.data?.entries ?? []) {\n      allEntries.push(entry);\n      if (entry.next == null && entry.prev == null) {\n        externalEntries.push(entry);\n      } else {\n        yaakEntries.push(entry);\n      }\n    }\n    return { internalEntries: yaakEntries, externalEntries, allEntries };\n  }, [status.data?.entries]);\n\n  const hasAddedAnything = allEntries.find((e) => e.staged) != null;\n  const hasAnythingToAdd = allEntries.find((e) => e.status !== \"current\") != null;\n\n  const tree: CommitTreeNode | null = useMemo(() => {\n    const next = (\n      model: CommitTreeNode[\"model\"],\n      ancestors: CommitTreeNode[],\n    ): CommitTreeNode | null => {\n      const statusEntry = internalEntries?.find((s) => s.relaPath.includes(model.id));\n      if (statusEntry == null) {\n        return null;\n      }\n\n      const node: CommitTreeNode = {\n        model,\n        status: statusEntry,\n        children: [],\n        ancestors,\n      };\n\n      for (const entry of internalEntries) {\n        const childModel = entry.next ?? entry.prev;\n\n        // Should never happen because we're iterating internalEntries\n        if (childModel == null) continue;\n\n        // TODO: Figure out why not all of these show up\n        if (\"folderId\" in childModel && childModel.folderId != null) {\n          if (childModel.folderId === model.id) {\n            const c = next(childModel, [...ancestors, node]);\n            if (c != null) node.children.push(c);\n          }\n        } else if (\"workspaceId\" in childModel && childModel.workspaceId === model.id) {\n          const c = next(childModel, [...ancestors, node]);\n          if (c != null) node.children.push(c);\n        } else {\n          // Do nothing\n        }\n      }\n\n      return node;\n    };\n\n    return next(workspace, []);\n  }, [workspace, internalEntries]);\n\n  const checkNode = useCallback(\n    (treeNode: CommitTreeNode) => {\n      const checked = nodeCheckedStatus(treeNode);\n      const newChecked = checked === \"indeterminate\" ? true : !checked;\n      setCheckedAndChildren(treeNode, newChecked, unstage.mutate, add.mutate);\n      // TODO: Also ensure parents are added properly\n    },\n    [add.mutate, unstage.mutate],\n  );\n\n  const checkEntry = useCallback(\n    (entry: GitStatusEntry) => {\n      if (entry.staged) unstage.mutate({ relaPaths: [entry.relaPath] });\n      else add.mutate({ relaPaths: [entry.relaPath] });\n    },\n    [add.mutate, unstage.mutate],\n  );\n\n  const handleSelectChild = useCallback(\n    (entry: GitStatusEntry) => {\n      if (entry === selectedEntry) {\n        setSelectedEntry(null);\n      } else {\n        setSelectedEntry(entry);\n      }\n    },\n    [selectedEntry],\n  );\n\n  if (tree == null) {\n    return null;\n  }\n\n  if (!hasAnythingToAdd) {\n    return (\n      <div className=\"h-full px-6 pb-4\">\n        <EmptyStateText>No changes since last commit</EmptyStateText>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"h-full px-2 pb-4\">\n      <SplitLayout\n        name=\"commit-horizontal\"\n        layout=\"horizontal\"\n        defaultRatio={0.6}\n        firstSlot={({ style }) => (\n          <div style={style} className=\"h-full px-4\">\n            <SplitLayout\n              name=\"commit-vertical\"\n              layout=\"vertical\"\n              defaultRatio={0.35}\n              firstSlot={({ style: innerStyle }) => (\n                <div\n                  style={innerStyle}\n                  className=\"h-full overflow-y-auto pb-3 pr-0.5 transform-cpu\"\n                >\n                  <TreeNodeChildren\n                    node={tree}\n                    depth={0}\n                    onCheck={checkNode}\n                    onSelect={handleSelectChild}\n                    selectedPath={selectedEntry?.relaPath ?? null}\n                  />\n                  {externalEntries.find((e) => e.status !== \"current\") && (\n                    <>\n                      <Separator className=\"mt-3 mb-1\">External file changes</Separator>\n                      {externalEntries.map((entry) => (\n                        <ExternalTreeNode\n                          key={entry.relaPath + entry.status}\n                          entry={entry}\n                          onCheck={checkEntry}\n                        />\n                      ))}\n                    </>\n                  )}\n                </div>\n              )}\n              secondSlot={({ style: innerStyle }) => (\n                <div style={innerStyle} className=\"grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2\">\n                  <Input\n                    className=\"!text-base font-sans rounded-md\"\n                    placeholder=\"Commit message...\"\n                    onChange={setMessage}\n                    stateKey={null}\n                    label=\"Commit message\"\n                    fullHeight\n                    multiLine\n                    hideLabel\n                  />\n                  {commitError && <Banner color=\"danger\">{commitError}</Banner>}\n                  <HStack alignItems=\"center\" space={2}>\n                    <InlineCode>{status.data?.headRefShorthand}</InlineCode>\n                    <HStack space={2} className=\"ml-auto\">\n                      <Button\n                        color=\"secondary\"\n                        size=\"sm\"\n                        onClick={handleCreateCommit}\n                        disabled={!hasAddedAnything || message.trim().length === 0}\n                        isLoading={isPushing}\n                      >\n                        Commit\n                      </Button>\n                      <Button\n                        color=\"primary\"\n                        size=\"sm\"\n                        disabled={!hasAddedAnything || message.trim().length === 0}\n                        onClick={handleCreateCommitAndPush}\n                        isLoading={isPushing}\n                      >\n                        Commit and Push\n                      </Button>\n                    </HStack>\n                  </HStack>\n                </div>\n              )}\n            />\n          </div>\n        )}\n        secondSlot={({ style }) => (\n          <div style={style} className=\"h-full px-4 border-l border-l-border-subtle\">\n            {selectedEntry ? (\n              <DiffPanel entry={selectedEntry} />\n            ) : (\n              <EmptyStateText>Select a change to view diff</EmptyStateText>\n            )}\n          </div>\n        )}\n      />\n    </div>\n  );\n}\n\nfunction TreeNodeChildren({\n  node,\n  depth,\n  onCheck,\n  onSelect,\n  selectedPath,\n}: {\n  node: CommitTreeNode | null;\n  depth: number;\n  onCheck: (node: CommitTreeNode, checked: boolean) => void;\n  onSelect: (entry: GitStatusEntry) => void;\n  selectedPath: string | null;\n}) {\n  if (node === null) return null;\n  if (!isNodeRelevant(node)) return null;\n\n  const checked = nodeCheckedStatus(node);\n  const isSelected = selectedPath === node.status.relaPath;\n\n  return (\n    <div\n      className={classNames(\n        depth > 0 && \"pl-4 ml-2 border-l border-dashed border-border-subtle relative\",\n      )}\n    >\n      <div\n        className={classNames(\n          \"relative flex gap-1 w-full h-xs items-center\",\n          isSelected ? \"text-text\" : \"text-text-subtle\",\n        )}\n      >\n        {isSelected && (\n          <div className=\"absolute -left-[100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10\" />\n        )}\n        <Checkbox\n          checked={checked}\n          title={checked ? \"Unstage change\" : \"Stage change\"}\n          hideLabel\n          onChange={(checked) => onCheck(node, checked)}\n        />\n        <button\n          type=\"button\"\n          className={classNames(\"flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left\")}\n          onClick={() => node.status.status !== \"current\" && onSelect(node.status)}\n        >\n          {node.model.model !== \"http_request\" &&\n          node.model.model !== \"grpc_request\" &&\n          node.model.model !== \"websocket_request\" ? (\n            <Icon\n              color=\"secondary\"\n              icon={\n                node.model.model === \"folder\"\n                  ? \"folder\"\n                  : node.model.model === \"environment\"\n                    ? \"variable\"\n                    : \"house\"\n              }\n            />\n          ) : (\n            <span aria-hidden className=\"w-4\" />\n          )}\n          <div className=\"truncate flex-1\">{resolvedModelName(node.model)}</div>\n          {node.status.status !== \"current\" && (\n            <InlineCode\n              className={classNames(\n                \"py-0 bg-transparent w-[6rem] text-center shrink-0\",\n                node.status.status === \"modified\" && \"text-info\",\n                node.status.status === \"untracked\" && \"text-success\",\n                node.status.status === \"removed\" && \"text-danger\",\n              )}\n            >\n              {node.status.status}\n            </InlineCode>\n          )}\n        </button>\n      </div>\n\n      {node.children.map((childNode) => {\n        return (\n          <TreeNodeChildren\n            key={childNode.status.relaPath + childNode.status.status + childNode.status.staged}\n            node={childNode}\n            depth={depth + 1}\n            onCheck={onCheck}\n            onSelect={onSelect}\n            selectedPath={selectedPath}\n          />\n        );\n      })}\n    </div>\n  );\n}\n\nfunction ExternalTreeNode({\n  entry,\n  onCheck,\n}: {\n  entry: GitStatusEntry;\n  onCheck: (entry: GitStatusEntry) => void;\n}) {\n  if (entry.status === \"current\") {\n    return null;\n  }\n\n  return (\n    <Checkbox\n      fullWidth\n      className=\"h-xs w-full hover:bg-surface-highlight rounded px-1 group\"\n      checked={entry.staged}\n      onChange={() => onCheck(entry)}\n      title={\n        <div className=\"grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center\">\n          <Icon color=\"secondary\" icon=\"file_code\" />\n          <div className=\"truncate\">{entry.relaPath}</div>\n          <InlineCode\n            className={classNames(\n              \"py-0 ml-auto bg-transparent w-[6rem] text-center\",\n              entry.status === \"modified\" && \"text-info\",\n              entry.status === \"untracked\" && \"text-success\",\n              entry.status === \"removed\" && \"text-danger\",\n            )}\n          >\n            {entry.status}\n          </InlineCode>\n        </div>\n      }\n    />\n  );\n}\n\nfunction nodeCheckedStatus(root: CommitTreeNode): CheckboxProps[\"checked\"] {\n  let numVisited = 0;\n  let numChecked = 0;\n  let numCurrent = 0;\n\n  const visitChildren = (n: CommitTreeNode) => {\n    numVisited += 1;\n    if (n.status.status === \"current\") {\n      numCurrent += 1;\n    } else if (n.status.staged) {\n      numChecked += 1;\n    }\n    for (const child of n.children) {\n      visitChildren(child);\n    }\n  };\n\n  visitChildren(root);\n\n  if (numVisited === numChecked + numCurrent) {\n    return true;\n  }\n  if (numChecked === 0) {\n    return false;\n  }\n  return \"indeterminate\";\n}\n\nfunction setCheckedAndChildren(\n  node: CommitTreeNode,\n  checked: boolean,\n  unstage: (args: { relaPaths: string[] }) => void,\n  add: (args: { relaPaths: string[] }) => void,\n) {\n  const toAdd: string[] = [];\n  const toUnstage: string[] = [];\n\n  const next = (node: CommitTreeNode) => {\n    for (const child of node.children) {\n      next(child);\n    }\n\n    if (node.status.status === \"current\") {\n      // Nothing required\n    } else if (checked && !node.status.staged) {\n      toAdd.push(node.status.relaPath);\n    } else if (!checked && node.status.staged) {\n      toUnstage.push(node.status.relaPath);\n    }\n  };\n\n  next(node);\n\n  if (toAdd.length > 0) add({ relaPaths: toAdd });\n  if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });\n}\n\nfunction isNodeRelevant(node: CommitTreeNode): boolean {\n  if (node.status.status !== \"current\") {\n    return true;\n  }\n\n  // Recursively check children\n  return node.children.some((c) => isNodeRelevant(c));\n}\n\nfunction DiffPanel({ entry }: { entry: GitStatusEntry }) {\n  const prevYaml = modelToYaml(entry.prev);\n  const nextYaml = modelToYaml(entry.next);\n\n  return (\n    <div className=\"h-full flex flex-col\">\n      <div className=\"text-sm text-text-subtle mb-2 px-1\">\n        {resolvedModelName(entry.next ?? entry.prev)} ({entry.status})\n      </div>\n      <DiffViewer original={prevYaml ?? \"\"} modified={nextYaml ?? \"\"} className=\"flex-1 min-h-0\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/git/GitDropdown.tsx",
    "content": "import { useGit } from \"@yaakapp-internal/git\";\nimport type { WorkspaceMeta } from \"@yaakapp-internal/models\";\nimport classNames from \"classnames\";\nimport { useAtomValue } from \"jotai\";\nimport type { HTMLAttributes } from \"react\";\nimport { forwardRef } from \"react\";\nimport { openWorkspaceSettings } from \"../../commands/openWorkspaceSettings\";\nimport { activeWorkspaceAtom, activeWorkspaceMetaAtom } from \"../../hooks/useActiveWorkspace\";\nimport { useKeyValue } from \"../../hooks/useKeyValue\";\nimport { useRandomKey } from \"../../hooks/useRandomKey\";\nimport { sync } from \"../../init/sync\";\nimport { showConfirm, showConfirmDelete } from \"../../lib/confirm\";\nimport { showDialog } from \"../../lib/dialog\";\nimport { fireAndForget } from \"../../lib/fireAndForget\";\nimport { showPrompt } from \"../../lib/prompt\";\nimport { showErrorToast, showToast } from \"../../lib/toast\";\nimport { Banner } from \"../core/Banner\";\nimport type { DropdownItem } from \"../core/Dropdown\";\nimport { Dropdown } from \"../core/Dropdown\";\nimport { Icon } from \"../core/Icon\";\nimport { InlineCode } from \"../core/InlineCode\";\nimport { gitCallbacks } from \"./callbacks\";\nimport { GitCommitDialog } from \"./GitCommitDialog\";\nimport { GitRemotesDialog } from \"./GitRemotesDialog\";\nimport { handlePullResult, handlePushResult } from \"./git-util\";\nimport { HistoryDialog } from \"./HistoryDialog\";\n\nexport function GitDropdown() {\n  const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);\n  if (workspaceMeta == null) return null;\n\n  if (workspaceMeta.settingSyncDir == null) {\n    return <SetupSyncDropdown workspaceMeta={workspaceMeta} />;\n  }\n\n  return <SyncDropdownWithSyncDir syncDir={workspaceMeta.settingSyncDir} />;\n}\n\nfunction SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {\n  const workspace = useAtomValue(activeWorkspaceAtom);\n  const [refreshKey, regenerateKey] = useRandomKey();\n  const [\n    { status, log },\n    {\n      createBranch,\n      deleteBranch,\n      deleteRemoteBranch,\n      renameBranch,\n      mergeBranch,\n      push,\n      pull,\n      checkout,\n      resetChanges,\n      init,\n    },\n  ] = useGit(syncDir, gitCallbacks(syncDir), refreshKey);\n\n  const localBranches = status.data?.localBranches ?? [];\n  const remoteBranches = status.data?.remoteBranches ?? [];\n  const remoteOnlyBranches = remoteBranches.filter(\n    (b) => !localBranches.includes(b.replace(/^origin\\//, \"\")),\n  );\n  if (workspace == null) {\n    return null;\n  }\n\n  const noRepo = status.error?.includes(\"not found\");\n  if (noRepo) {\n    return <SetupGitDropdown workspaceId={workspace.id} initRepo={init.mutate} />;\n  }\n\n  // Still loading\n  if (status.data == null) {\n    return null;\n  }\n\n  const currentBranch = status.data.headRefShorthand;\n  const hasChanges = status.data.entries.some((e) => e.status !== \"current\");\n  const _hasRemotes = (status.data.origins ?? []).length > 0;\n  const { ahead, behind } = status.data;\n\n  const tryCheckout = (branch: string, force: boolean) => {\n    checkout.mutate(\n      { branch, force },\n      {\n        disableToastError: true,\n        async onError(err) {\n          if (!force) {\n            // Checkout failed so ask user if they want to force it\n            const forceCheckout = await showConfirm({\n              id: \"git-force-checkout\",\n              title: \"Conflicts Detected\",\n              description:\n                \"Your branch has conflicts. Either make a commit or force checkout to discard changes.\",\n              confirmText: \"Force Checkout\",\n              color: \"warning\",\n            });\n            if (forceCheckout) {\n              tryCheckout(branch, true);\n            }\n          } else {\n            // Checkout failed\n            showErrorToast({\n              id: \"git-checkout-error\",\n              title: \"Error checking out branch\",\n              message: String(err),\n            });\n          }\n        },\n        async onSuccess(branchName) {\n          showToast({\n            id: \"git-checkout-success\",\n            message: (\n              <>\n                Switched branch <InlineCode>{branchName}</InlineCode>\n              </>\n            ),\n            color: \"success\",\n          });\n          await sync({ force: true });\n        },\n      },\n    );\n  };\n\n  const items: DropdownItem[] = [\n    {\n      label: \"View History...\",\n      hidden: (log.data ?? []).length === 0,\n      leftSlot: <Icon icon=\"history\" />,\n      onSelect: async () => {\n        showDialog({\n          id: \"git-history\",\n          size: \"md\",\n          title: \"Commit History\",\n          noPadding: true,\n          render: () => <HistoryDialog log={log.data ?? []} />,\n        });\n      },\n    },\n    {\n      label: \"Manage Remotes...\",\n      leftSlot: <Icon icon=\"hard_drive_download\" />,\n      onSelect: () => GitRemotesDialog.show(syncDir),\n    },\n    { type: \"separator\" },\n    {\n      label: \"New Branch...\",\n      leftSlot: <Icon icon=\"git_branch_plus\" />,\n      async onSelect() {\n        const name = await showPrompt({\n          id: \"git-branch-name\",\n          title: \"Create Branch\",\n          label: \"Branch Name\",\n        });\n        if (!name) return;\n\n        await createBranch.mutateAsync(\n          { branch: name },\n          {\n            disableToastError: true,\n            onError: (err) => {\n              showErrorToast({\n                id: \"git-branch-error\",\n                title: \"Error creating branch\",\n                message: String(err),\n              });\n            },\n          },\n        );\n        tryCheckout(name, false);\n      },\n    },\n    { type: \"separator\" },\n    {\n      label: \"Push\",\n      leftSlot: <Icon icon=\"arrow_up_from_line\" />,\n      waitForOnSelect: true,\n      async onSelect() {\n        await push.mutateAsync(undefined, {\n          disableToastError: true,\n          onSuccess: handlePushResult,\n          onError(err) {\n            showErrorToast({\n              id: \"git-push-error\",\n              title: \"Error pushing changes\",\n              message: String(err),\n            });\n          },\n        });\n      },\n    },\n    {\n      label: \"Pull\",\n      leftSlot: <Icon icon=\"arrow_down_to_line\" />,\n      waitForOnSelect: true,\n      async onSelect() {\n        await pull.mutateAsync(undefined, {\n          disableToastError: true,\n          onSuccess: handlePullResult,\n          onError(err) {\n            showErrorToast({\n              id: \"git-pull-error\",\n              title: \"Error pulling changes\",\n              message: String(err),\n            });\n          },\n        });\n      },\n    },\n    {\n      label: \"Commit...\",\n\n      leftSlot: <Icon icon=\"git_commit_vertical\" />,\n      onSelect() {\n        showDialog({\n          id: \"commit\",\n          title: \"Commit Changes\",\n          size: \"full\",\n          noPadding: true,\n          render: ({ hide }) => (\n            <GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />\n          ),\n        });\n      },\n    },\n    {\n      label: \"Reset Changes\",\n      hidden: !hasChanges,\n      leftSlot: <Icon icon=\"rotate_ccw\" />,\n      color: \"danger\",\n      async onSelect() {\n        const confirmed = await showConfirm({\n          id: \"git-reset-changes\",\n          title: \"Reset Changes\",\n          description: \"This will discard all uncommitted changes. This cannot be undone.\",\n          confirmText: \"Reset\",\n          color: \"danger\",\n        });\n        if (!confirmed) return;\n\n        await resetChanges.mutateAsync(undefined, {\n          disableToastError: true,\n          onSuccess() {\n            showToast({\n              id: \"git-reset-success\",\n              message: \"Changes have been reset\",\n              color: \"success\",\n            });\n            fireAndForget(sync({ force: true }));\n          },\n          onError(err) {\n            showErrorToast({\n              id: \"git-reset-error\",\n              title: \"Error resetting changes\",\n              message: String(err),\n            });\n          },\n        });\n      },\n    },\n    { type: \"separator\", label: \"Branches\", hidden: localBranches.length < 1 },\n    ...localBranches.map((branch) => {\n      const isCurrent = currentBranch === branch;\n      return {\n        label: branch,\n        leftSlot: <Icon icon={isCurrent ? \"check\" : \"empty\"} />,\n        submenuOpenOnClick: true,\n        submenu: [\n          {\n            label: \"Checkout\",\n            hidden: isCurrent,\n            onSelect: () => tryCheckout(branch, false),\n          },\n          {\n            label: (\n              <>\n                Merge into <InlineCode>{currentBranch}</InlineCode>\n              </>\n            ),\n            hidden: isCurrent,\n            async onSelect() {\n              await mergeBranch.mutateAsync(\n                { branch },\n                {\n                  disableToastError: true,\n                  onSuccess() {\n                    showToast({\n                      id: \"git-merged-branch\",\n                      message: (\n                        <>\n                          Merged <InlineCode>{branch}</InlineCode> into{\" \"}\n                          <InlineCode>{currentBranch}</InlineCode>\n                        </>\n                      ),\n                    });\n                    fireAndForget(sync({ force: true }));\n                  },\n                  onError(err) {\n                    showErrorToast({\n                      id: \"git-merged-branch-error\",\n                      title: \"Error merging branch\",\n                      message: String(err),\n                    });\n                  },\n                },\n              );\n            },\n          },\n          {\n            label: \"New Branch...\",\n            async onSelect() {\n              const name = await showPrompt({\n                id: \"git-new-branch-from\",\n                title: \"New Branch\",\n                description: (\n                  <>\n                    Create a new branch from <InlineCode>{branch}</InlineCode>\n                  </>\n                ),\n                label: \"Branch Name\",\n              });\n              if (!name) return;\n\n              await createBranch.mutateAsync(\n                { branch: name, base: branch },\n                {\n                  disableToastError: true,\n                  onError: (err) => {\n                    showErrorToast({\n                      id: \"git-branch-error\",\n                      title: \"Error creating branch\",\n                      message: String(err),\n                    });\n                  },\n                },\n              );\n              tryCheckout(name, false);\n            },\n          },\n          {\n            label: \"Rename...\",\n            async onSelect() {\n              const newName = await showPrompt({\n                id: \"git-rename-branch\",\n                title: \"Rename Branch\",\n                label: \"New Branch Name\",\n                defaultValue: branch,\n              });\n              if (!newName || newName === branch) return;\n\n              await renameBranch.mutateAsync(\n                { oldName: branch, newName },\n                {\n                  disableToastError: true,\n                  onSuccess() {\n                    showToast({\n                      id: \"git-rename-branch-success\",\n                      message: (\n                        <>\n                          Renamed <InlineCode>{branch}</InlineCode> to{\" \"}\n                          <InlineCode>{newName}</InlineCode>\n                        </>\n                      ),\n                      color: \"success\",\n                    });\n                  },\n                  onError(err) {\n                    showErrorToast({\n                      id: \"git-rename-branch-error\",\n                      title: \"Error renaming branch\",\n                      message: String(err),\n                    });\n                  },\n                },\n              );\n            },\n          },\n          { type: \"separator\", hidden: isCurrent },\n          {\n            label: \"Delete\",\n            color: \"danger\",\n            hidden: isCurrent,\n            onSelect: async () => {\n              const confirmed = await showConfirmDelete({\n                id: \"git-delete-branch\",\n                title: \"Delete Branch\",\n                description: (\n                  <>\n                    Permanently delete <InlineCode>{branch}</InlineCode>?\n                  </>\n                ),\n              });\n              if (!confirmed) {\n                return;\n              }\n\n              const result = await deleteBranch.mutateAsync(\n                { branch },\n                {\n                  disableToastError: true,\n                  onError(err) {\n                    showErrorToast({\n                      id: \"git-delete-branch-error\",\n                      title: \"Error deleting branch\",\n                      message: String(err),\n                    });\n                  },\n                },\n              );\n\n              if (result.type === \"not_fully_merged\") {\n                const confirmed = await showConfirm({\n                  id: \"force-branch-delete\",\n                  title: \"Branch not fully merged\",\n                  description: (\n                    <>\n                      <p>\n                        Branch <InlineCode>{branch}</InlineCode> is not fully merged.\n                      </p>\n                      <p>Do you want to delete it anyway?</p>\n                    </>\n                  ),\n                });\n                if (confirmed) {\n                  await deleteBranch.mutateAsync(\n                    { branch, force: true },\n                    {\n                      disableToastError: true,\n                      onError(err) {\n                        showErrorToast({\n                          id: \"git-force-delete-branch-error\",\n                          title: \"Error force deleting branch\",\n                          message: String(err),\n                        });\n                      },\n                    },\n                  );\n                }\n              }\n            },\n          },\n        ],\n      } satisfies DropdownItem;\n    }),\n    ...remoteOnlyBranches.map((branch) => {\n      const isCurrent = currentBranch === branch;\n      return {\n        label: branch,\n        leftSlot: <Icon icon={isCurrent ? \"check\" : \"empty\"} />,\n        submenuOpenOnClick: true,\n        submenu: [\n          {\n            label: \"Checkout\",\n            hidden: isCurrent,\n            onSelect: () => tryCheckout(branch, false),\n          },\n          {\n            label: \"Delete\",\n            color: \"danger\",\n            async onSelect() {\n              const confirmed = await showConfirmDelete({\n                id: \"git-delete-remote-branch\",\n                title: \"Delete Remote Branch\",\n                description: (\n                  <>\n                    Permanently delete <InlineCode>{branch}</InlineCode> from the remote?\n                  </>\n                ),\n              });\n              if (!confirmed) return;\n\n              await deleteRemoteBranch.mutateAsync(\n                { branch },\n                {\n                  disableToastError: true,\n                  onSuccess() {\n                    showToast({\n                      id: \"git-delete-remote-branch-success\",\n                      message: (\n                        <>\n                          Deleted remote branch <InlineCode>{branch}</InlineCode>\n                        </>\n                      ),\n                      color: \"success\",\n                    });\n                  },\n                  onError(err) {\n                    showErrorToast({\n                      id: \"git-delete-remote-branch-error\",\n                      title: \"Error deleting remote branch\",\n                      message: String(err),\n                    });\n                  },\n                },\n              );\n            },\n          },\n        ],\n      } satisfies DropdownItem;\n    }),\n  ];\n\n  return (\n    <Dropdown fullWidth items={items} onOpen={regenerateKey}>\n      <GitMenuButton>\n        <InlineCode className=\"flex items-center gap-1\">\n          <Icon icon=\"git_branch\" size=\"xs\" className=\"opacity-50\" />\n          {currentBranch}\n        </InlineCode>\n        <div className=\"flex items-center gap-1.5\">\n          {ahead > 0 && (\n            <span className=\"text-xs flex items-center gap-0.5\">\n              <span className=\"text-primary\">↗</span>\n              {ahead}\n            </span>\n          )}\n          {behind > 0 && (\n            <span className=\"text-xs flex items-center gap-0.5\">\n              <span className=\"text-info\">↙</span>\n              {behind}\n            </span>\n          )}\n        </div>\n      </GitMenuButton>\n    </Dropdown>\n  );\n}\n\nconst GitMenuButton = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonElement>>(\n  function GitMenuButton({ className, ...props }: HTMLAttributes<HTMLButtonElement>, ref) {\n    return (\n      <button\n        ref={ref}\n        className={classNames(\n          className,\n          \"px-3 h-md border-t border-border flex items-center justify-between text-text-subtle outline-none focus-visible:bg-surface-highlight\",\n        )}\n        {...props}\n      />\n    );\n  },\n);\n\nfunction SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta }) {\n  const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({\n    key: \"setup_sync\",\n    fallback: {},\n  });\n\n  if (hidden == null || hidden[workspaceMeta.workspaceId]) {\n    return null;\n  }\n\n  const banner = (\n    <Banner color=\"info\">\n      When enabled, workspace data syncs to the chosen folder as text files, ideal for backup and\n      Git collaboration.\n    </Banner>\n  );\n\n  return (\n    <Dropdown\n      fullWidth\n      items={[\n        {\n          type: \"content\",\n          label: banner,\n        },\n        {\n          color: \"success\",\n          label: \"Open Workspace Settings\",\n          leftSlot: <Icon icon=\"settings\" />,\n          onSelect: () => openWorkspaceSettings(\"data\"),\n        },\n        { type: \"separator\" },\n        {\n          label: \"Hide This Message\",\n          leftSlot: <Icon icon=\"eye_closed\" />,\n          async onSelect() {\n            const confirmed = await showConfirm({\n              id: \"hide-sync-menu-prompt\",\n              title: \"Hide Setup Message\",\n              description: \"You can configure filesystem sync or Git it in the workspace settings\",\n            });\n            if (confirmed) {\n              await setHidden((prev) => ({ ...prev, [workspaceMeta.workspaceId]: true }));\n            }\n          },\n        },\n      ]}\n    >\n      <GitMenuButton>\n        <div className=\"text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2\">\n          <Icon icon=\"wrench\" />\n          <div className=\"truncate\">Setup FS Sync or Git</div>\n        </div>\n      </GitMenuButton>\n    </Dropdown>\n  );\n}\n\nfunction SetupGitDropdown({\n  workspaceId,\n  initRepo,\n}: {\n  workspaceId: string;\n  initRepo: () => void;\n}) {\n  const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({\n    key: \"setup_git_repo\",\n    fallback: {},\n  });\n\n  if (hidden == null || hidden[workspaceId]) {\n    return null;\n  }\n\n  const banner = <Banner color=\"info\">Initialize local repo to start versioning with Git</Banner>;\n\n  return (\n    <Dropdown\n      fullWidth\n      items={[\n        { type: \"content\", label: banner },\n        {\n          label: \"Initialize Git Repo\",\n          leftSlot: <Icon icon=\"magic_wand\" />,\n          onSelect: initRepo,\n        },\n        { type: \"separator\" },\n        {\n          label: \"Hide This Message\",\n          leftSlot: <Icon icon=\"eye_closed\" />,\n          async onSelect() {\n            const confirmed = await showConfirm({\n              id: \"hide-git-init-prompt\",\n              title: \"Hide Git Setup\",\n              description: \"You can initialize a git repo outside of Yaak to bring this back\",\n            });\n            if (confirmed) {\n              await setHidden((prev) => ({ ...prev, [workspaceId]: true }));\n            }\n          },\n        },\n      ]}\n    >\n      <GitMenuButton>\n        <div className=\"text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2\">\n          <Icon icon=\"folder_git\" />\n          <div className=\"truncate\">Setup Git</div>\n        </div>\n      </GitMenuButton>\n    </Dropdown>\n  );\n}\n"
  },
  {
    "path": "src-web/components/git/GitRemotesDialog.tsx",
    "content": "import { useGit } from \"@yaakapp-internal/git\";\nimport { showDialog } from \"../../lib/dialog\";\nimport { Button } from \"../core/Button\";\nimport { IconButton } from \"../core/IconButton\";\nimport { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from \"../core/Table\";\nimport { gitCallbacks } from \"./callbacks\";\nimport { addGitRemote } from \"./showAddRemoteDialog\";\n\ninterface Props {\n  dir: string;\n  onDone: () => void;\n}\n\nexport function GitRemotesDialog({ dir }: Props) {\n  const [{ remotes }, { rmRemote }] = useGit(dir, gitCallbacks(dir));\n\n  return (\n    <Table scrollable>\n      <TableHead>\n        <TableRow>\n          <TableHeaderCell>Name</TableHeaderCell>\n          <TableHeaderCell>URL</TableHeaderCell>\n          <TableHeaderCell>\n            <Button\n              className=\"text-text-subtle ml-auto\"\n              size=\"2xs\"\n              color=\"primary\"\n              title=\"Add remote\"\n              variant=\"border\"\n              onClick={() => addGitRemote(dir)}\n            >\n              Add Remote\n            </Button>\n          </TableHeaderCell>\n        </TableRow>\n      </TableHead>\n      <TableBody>\n        {remotes.data?.map((r) => (\n          <TableRow key={r.name + r.url}>\n            <TableCell>{r.name}</TableCell>\n            <TableCell>{r.url}</TableCell>\n            <TableCell>\n              <IconButton\n                size=\"sm\"\n                className=\"text-text-subtle ml-auto\"\n                icon=\"trash\"\n                title=\"Remove remote\"\n                onClick={() => rmRemote.mutate({ name: r.name })}\n              />\n            </TableCell>\n          </TableRow>\n        ))}\n      </TableBody>\n    </Table>\n  );\n}\n\nGitRemotesDialog.show = (dir: string) => {\n  showDialog({\n    id: \"git-remotes\",\n    title: \"Manage Remotes\",\n    size: \"md\",\n    render: ({ hide }) => <GitRemotesDialog onDone={hide} dir={dir} />,\n  });\n};\n"
  },
  {
    "path": "src-web/components/git/HistoryDialog.tsx",
    "content": "import type { GitCommit } from \"@yaakapp-internal/git\";\nimport { formatDistanceToNowStrict } from \"date-fns\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeaderCell,\n  TableRow,\n  TruncatedWideTableCell,\n} from \"../core/Table\";\n\ninterface Props {\n  log: GitCommit[];\n}\n\nexport function HistoryDialog({ log }: Props) {\n  return (\n    <div className=\"pl-5 pr-1 pb-1\">\n      <Table scrollable className=\"px-1\">\n        <TableHead>\n          <TableRow>\n            <TableHeaderCell>Message</TableHeaderCell>\n            <TableHeaderCell>Author</TableHeaderCell>\n            <TableHeaderCell>When</TableHeaderCell>\n          </TableRow>\n        </TableHead>\n        <TableBody>\n          {log.map((l) => (\n            <TableRow\n              key={(l.author.name ?? \"\") + (l.author.email ?? \"\") + (l.message ?? \"n/a\") + l.when}\n            >\n              <TruncatedWideTableCell>\n                {l.message || <em className=\"text-text-subtle\">No message</em>}\n              </TruncatedWideTableCell>\n              <TableCell>\n                <span title={`Email: ${l.author.email}`}>{l.author.name || \"Unknown\"}</span>\n              </TableCell>\n              <TableCell className=\"text-text-subtle\">\n                <span title={l.when}>{formatDistanceToNowStrict(l.when)} ago</span>\n              </TableCell>\n            </TableRow>\n          ))}\n        </TableBody>\n      </Table>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/git/callbacks.tsx",
    "content": "import type { GitCallbacks } from \"@yaakapp-internal/git\";\nimport { sync } from \"../../init/sync\";\nimport { promptCredentials } from \"./credentials\";\nimport { promptDivergedStrategy } from \"./diverged\";\nimport { addGitRemote } from \"./showAddRemoteDialog\";\nimport { promptUncommittedChangesStrategy } from \"./uncommitted\";\n\nexport function gitCallbacks(dir: string): GitCallbacks {\n  return {\n    addRemote: async () => {\n      return addGitRemote(dir, \"origin\");\n    },\n    promptCredentials: async ({ url, error }) => {\n      const creds = await promptCredentials({ url, error });\n      if (creds == null) throw new Error(\"Cancelled credentials prompt\");\n      return creds;\n    },\n    promptDiverged: async ({ remote, branch }) => {\n      return promptDivergedStrategy({ remote, branch });\n    },\n    promptUncommittedChanges: async () => {\n      return promptUncommittedChangesStrategy();\n    },\n    forceSync: () => sync({ force: true }),\n  };\n}\n"
  },
  {
    "path": "src-web/components/git/credentials.tsx",
    "content": "import { showPromptForm } from \"../../lib/prompt-form\";\nimport { Banner } from \"../core/Banner\";\nimport { InlineCode } from \"../core/InlineCode\";\n\nexport interface GitCredentials {\n  username: string;\n  password: string;\n}\n\nexport async function promptCredentials({\n  url: remoteUrl,\n  error,\n}: {\n  url: string;\n  error: string | null;\n}): Promise<GitCredentials | null> {\n  const isGitHub = /github\\.com/i.test(remoteUrl);\n  const userLabel = isGitHub ? \"GitHub Username\" : \"Username\";\n  const passLabel = isGitHub ? \"GitHub Personal Access Token\" : \"Password / Token\";\n  const userDescription = isGitHub ? \"Use your GitHub username (not your email).\" : undefined;\n  const passDescription = isGitHub\n    ? \"GitHub requires a Personal Access Token (PAT) for write operations over HTTPS. Passwords are not supported.\"\n    : \"Enter your password or access token for this Git server.\";\n  const r = await showPromptForm({\n    id: \"git-credentials\",\n    title: \"Credentials Required\",\n    description: error ? (\n      <Banner color=\"danger\">{error}</Banner>\n    ) : (\n      <>\n        Enter credentials for <InlineCode>{remoteUrl}</InlineCode>\n      </>\n    ),\n    inputs: [\n      { type: \"text\", name: \"username\", label: userLabel, description: userDescription },\n      {\n        type: \"text\",\n        name: \"password\",\n        label: passLabel,\n        description: passDescription,\n        password: true,\n      },\n    ],\n  });\n  if (r == null) return null;\n\n  const username = String(r.username || \"\");\n  const password = String(r.password || \"\");\n  return { username, password };\n}\n"
  },
  {
    "path": "src-web/components/git/diverged.tsx",
    "content": "import type { DivergedStrategy } from \"@yaakapp-internal/git\";\nimport { useState } from \"react\";\nimport { showDialog } from \"../../lib/dialog\";\nimport { Button } from \"../core/Button\";\nimport { InlineCode } from \"../core/InlineCode\";\nimport { RadioCards } from \"../core/RadioCards\";\nimport { HStack } from \"../core/Stacks\";\n\ntype Resolution = \"force_reset\" | \"merge\";\n\nconst resolutionLabel: Record<Resolution, string> = {\n  force_reset: \"Force Pull\",\n  merge: \"Merge\",\n};\n\ninterface DivergedDialogProps {\n  remote: string;\n  branch: string;\n  onResult: (strategy: DivergedStrategy) => void;\n  onHide: () => void;\n}\n\nfunction DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProps) {\n  const [selected, setSelected] = useState<Resolution | null>(null);\n\n  const handleSubmit = () => {\n    if (selected == null) return;\n    onResult(selected);\n    onHide();\n  };\n\n  const handleCancel = () => {\n    onResult(\"cancel\");\n    onHide();\n  };\n\n  return (\n    <div className=\"flex flex-col gap-4 mb-4\">\n      <p className=\"text-text-subtle\">\n        Your local branch has diverged from{\" \"}\n        <InlineCode>\n          {remote}/{branch}\n        </InlineCode>\n        . How would you like to resolve this?\n      </p>\n      <RadioCards\n        name=\"diverged-strategy\"\n        value={selected}\n        onChange={setSelected}\n        options={[\n          {\n            value: \"merge\",\n            label: \"Merge Commit\",\n            description: \"Combining local and remote changes into a single merge commit\",\n          },\n          {\n            value: \"force_reset\",\n            label: \"Force Pull\",\n            description: \"Discard local commits and reset to match the remote branch\",\n          },\n        ]}\n      />\n      <HStack space={2} justifyContent=\"start\" className=\"flex-row-reverse\">\n        <Button\n          color={selected === \"force_reset\" ? \"danger\" : \"primary\"}\n          disabled={selected == null}\n          onClick={handleSubmit}\n        >\n          {selected != null ? resolutionLabel[selected] : \"Select an option\"}\n        </Button>\n        <Button variant=\"border\" onClick={handleCancel}>\n          Cancel\n        </Button>\n      </HStack>\n    </div>\n  );\n}\n\nexport async function promptDivergedStrategy({\n  remote,\n  branch,\n}: {\n  remote: string;\n  branch: string;\n}): Promise<DivergedStrategy> {\n  return new Promise((resolve) => {\n    showDialog({\n      id: \"git-diverged\",\n      title: \"Branches Diverged\",\n      hideX: true,\n      size: \"sm\",\n      disableBackdropClose: true,\n      onClose: () => resolve(\"cancel\"),\n      render: ({ hide }) =>\n        DivergedDialog({\n          remote,\n          branch,\n          onHide: hide,\n          onResult: resolve,\n        }),\n    });\n  });\n}\n"
  },
  {
    "path": "src-web/components/git/git-util.ts",
    "content": "import type { PullResult, PushResult } from \"@yaakapp-internal/git\";\nimport { showToast } from \"../../lib/toast\";\n\nexport function handlePushResult(r: PushResult) {\n  switch (r.type) {\n    case \"needs_credentials\":\n      showToast({ id: \"push-error\", message: \"Credentials not found\", color: \"danger\" });\n      break;\n    case \"success\":\n      showToast({ id: \"push-success\", message: r.message, color: \"success\" });\n      break;\n    case \"up_to_date\":\n      showToast({ id: \"push-nothing\", message: \"Already up-to-date\", color: \"info\" });\n      break;\n  }\n}\n\nexport function handlePullResult(r: PullResult) {\n  switch (r.type) {\n    case \"needs_credentials\":\n      showToast({ id: \"pull-error\", message: \"Credentials not found\", color: \"danger\" });\n      break;\n    case \"success\":\n      showToast({ id: \"pull-success\", message: r.message, color: \"success\" });\n      break;\n    case \"up_to_date\":\n      showToast({ id: \"pull-nothing\", message: \"Already up-to-date\", color: \"info\" });\n      break;\n    case \"diverged\":\n      // Handled by mutation callback before reaching here\n      break;\n    case \"uncommitted_changes\":\n      // Handled by mutation callback before reaching here\n      break;\n  }\n}\n"
  },
  {
    "path": "src-web/components/git/showAddRemoteDialog.tsx",
    "content": "import type { GitRemote } from \"@yaakapp-internal/git\";\nimport { gitMutations } from \"@yaakapp-internal/git\";\nimport { showPromptForm } from \"../../lib/prompt-form\";\nimport { gitCallbacks } from \"./callbacks\";\n\nexport async function addGitRemote(dir: string, defaultName?: string): Promise<GitRemote> {\n  const r = await showPromptForm({\n    id: \"add-remote\",\n    title: \"Add Remote\",\n    inputs: [\n      { type: \"text\", label: \"Name\", name: \"name\", defaultValue: defaultName },\n      { type: \"text\", label: \"URL\", name: \"url\" },\n    ],\n  });\n  if (r == null) throw new Error(\"Cancelled remote prompt\");\n\n  const name = String(r.name ?? \"\");\n  const url = String(r.url ?? \"\");\n  return gitMutations(dir, gitCallbacks(dir)).addRemote.mutateAsync({ name, url });\n}\n"
  },
  {
    "path": "src-web/components/git/uncommitted.tsx",
    "content": "import type { UncommittedChangesStrategy } from \"@yaakapp-internal/git\";\nimport { showConfirm } from \"../../lib/confirm\";\n\nexport async function promptUncommittedChangesStrategy(): Promise<UncommittedChangesStrategy> {\n  const confirmed = await showConfirm({\n    id: \"git-uncommitted-changes\",\n    title: \"Uncommitted Changes\",\n    description: \"You have uncommitted changes. Commit or reset your changes before pulling.\",\n    confirmText: \"Reset and Pull\",\n    color: \"danger\",\n  });\n  return confirmed ? \"reset\" : \"cancel\";\n}\n"
  },
  {
    "path": "src-web/components/graphql/GraphQLDocsExplorer.tsx",
    "content": "import type { Color } from \"@yaakapp-internal/plugins\";\nimport classNames from \"classnames\";\nimport { fuzzyMatch } from \"fuzzbunny\";\nimport type {\n  GraphQLField,\n  GraphQLInputField,\n  GraphQLNamedType,\n  GraphQLSchema,\n  GraphQLType,\n} from \"graphql\";\nimport {\n  getNamedType,\n  isEnumType,\n  isInputObjectType,\n  isInterfaceType,\n  isListType,\n  isNamedType,\n  isNonNullType,\n  isObjectType,\n  isScalarType,\n  isUnionType,\n} from \"graphql\";\nimport { useAtomValue } from \"jotai\";\nimport type { CSSProperties, HTMLAttributes, KeyboardEvent, ReactNode } from \"react\";\nimport { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { useClickOutside } from \"../../hooks/useClickOutside\";\nimport { useContainerSize } from \"../../hooks/useContainerQuery\";\nimport { useDebouncedValue } from \"../../hooks/useDebouncedValue\";\nimport { useStateWithDeps } from \"../../hooks/useStateWithDeps\";\nimport { jotaiStore } from \"../../lib/jotai\";\nimport { Banner } from \"../core/Banner\";\nimport { CountBadge } from \"../core/CountBadge\";\nimport { Icon } from \"../core/Icon\";\nimport { IconButton } from \"../core/IconButton\";\nimport { PlainInput } from \"../core/PlainInput\";\nimport { Markdown } from \"../Markdown\";\nimport { showGraphQLDocExplorerAtom } from \"./graphqlAtoms\";\n\ninterface Props {\n  style?: CSSProperties;\n  schema: GraphQLSchema;\n  requestId: string;\n  className?: string;\n}\n\ntype ExplorerItem =\n  | { kind: \"type\"; type: GraphQLType; from: ExplorerItem }\n  // oxlint-disable-next-line no-explicit-any\n  | { kind: \"field\"; type: GraphQLField<any, any>; from: ExplorerItem }\n  | { kind: \"input_field\"; type: GraphQLInputField; from: ExplorerItem }\n  | null;\n\nexport const GraphQLDocsExplorer = memo(function GraphQLDocsExplorer({\n  style,\n  schema,\n  requestId,\n  className,\n}: Props) {\n  const [activeItem, setActiveItem] = useState<ExplorerItem>(null);\n\n  const qryType = schema.getQueryType();\n  const mutType = schema.getMutationType();\n  const subType = schema.getSubscriptionType();\n  const showField = useAtomValue(showGraphQLDocExplorerAtom)[requestId] ?? null;\n\n  useEffect(() => {\n    if (showField === null) {\n      setActiveItem(null);\n    } else {\n      const isRootParentType =\n        showField.parentType === \"Query\" ||\n        showField.parentType === \"Mutation\" ||\n        showField.parentType === \"Subscription\";\n      walkTypeGraph(schema, null, (t, from) => {\n        if (\n          showField.field === t.name &&\n          // For input fields, CodeMirror seems to set parentType to the root type of the field they belong to.\n          (isRootParentType || from?.name === showField.parentType)\n        ) {\n          setActiveItem(toExplorerItem(t, toExplorerItem(from, null)));\n          return false;\n        }\n        if (showField.type === t.name && from?.name === showField.parentType) {\n          setActiveItem(toExplorerItem(t, toExplorerItem(from, null)));\n          return false;\n        }\n        return true;\n      });\n    }\n  }, [schema, showField]);\n\n  const qryItem: ExplorerItem = qryType ? { kind: \"type\", type: qryType, from: null } : null;\n  const mutItem: ExplorerItem = mutType ? { kind: \"type\", type: mutType, from: null } : null;\n  const subItem: ExplorerItem = subType ? { kind: \"type\", type: subType, from: null } : null;\n  const allTypes = schema.getTypeMap();\n  const containerRef = useRef<HTMLDivElement>(null);\n  const containerSize = useContainerSize(containerRef);\n\n  return (\n    <div ref={containerRef} className={classNames(className, \"py-3 mx-3\")} style={style}>\n      <div className=\"grid grid-rows-[auto_minmax(0,1fr)] h-full border border-dashed border-border rounded-lg overflow-hidden\">\n        <GraphQLExplorerHeader\n          containerHeight={containerSize.height}\n          item={activeItem}\n          onClose={() => {\n            jotaiStore.set(showGraphQLDocExplorerAtom, (v) => ({ ...v, [requestId]: undefined }));\n          }}\n          setItem={setActiveItem}\n          schema={schema}\n        />\n        {activeItem == null ? (\n          <div className=\"flex flex-col gap-3 overflow-y-auto h-full w-full px-3 pb-6\">\n            <Heading>Root Types</Heading>\n            <GqlTypeRow\n              name={{ value: \"query\", color: \"primary\" }}\n              item={qryItem}\n              setItem={setActiveItem}\n              className=\"!my-0\"\n            />\n            <GqlTypeRow\n              name={{ value: \"mutation\", color: \"primary\" }}\n              item={mutItem}\n              setItem={setActiveItem}\n              className=\"!my-0\"\n            />\n            <GqlTypeRow\n              name={{ value: \"subscription\", color: \"primary\" }}\n              item={subItem}\n              setItem={setActiveItem}\n              className=\"!my-0\"\n            />\n            <Subheading count={Object.keys(allTypes).length}>All Schema Types</Subheading>\n            <DocMarkdown>{schema.description ?? null}</DocMarkdown>\n            <div className=\"flex flex-col gap-1\">\n              {Object.values(allTypes).map((t) => {\n                return (\n                  <GqlTypeLink\n                    key={t.name}\n                    color=\"notice\"\n                    item={{ kind: \"type\", type: t, from: null }}\n                    setItem={setActiveItem}\n                  />\n                );\n              })}\n            </div>\n          </div>\n        ) : (\n          <div\n            key={\"name\" in activeItem.type ? activeItem.type.name : String(activeItem.type)} // Reset scroll position to top\n            className=\"overflow-y-auto h-full w-full p-3 grid grid-cols-[minmax(0,1fr)]\"\n          >\n            <GqlTypeInfo item={activeItem} setItem={setActiveItem} schema={schema} />\n          </div>\n        )}\n      </div>\n    </div>\n  );\n});\n\nfunction GraphQLExplorerHeader({\n  item,\n  setItem,\n  schema,\n  onClose,\n  containerHeight,\n}: {\n  item: ExplorerItem;\n  setItem: (t: ExplorerItem) => void;\n  schema: GraphQLSchema;\n  onClose: () => void;\n  containerHeight: number;\n}) {\n  const findIt = (t: ExplorerItem): ExplorerItem[] => {\n    if (t == null) return [null];\n    return [...findIt(t.from), t];\n  };\n  const crumbs = findIt(item);\n  return (\n    <nav className=\"pl-2 pr-1 h-lg grid grid-rows-1 grid-cols-[minmax(0,1fr)_auto] items-center min-w-0 gap-1 z-10\">\n      <div className=\"@container w-full relative pl-2 pr-1 h-lg grid grid-rows-1 grid-cols-[minmax(0,min-content)_auto] items-center gap-1\">\n        <div className=\"whitespace-nowrap flex items-center gap-2 text-text-subtle text-sm overflow-x-auto hide-scrollbars\">\n          <Icon icon=\"book_open_text\" />\n          {crumbs.map((crumb, i) => {\n            return (\n              // oxlint-disable-next-line react/no-array-index-key\n              <Fragment key={i}>\n                {i > 0 && <Icon icon=\"chevron_right\" className=\"text-text-subtlest\" />}\n                {crumb === item || item == null ? (\n                  <GqlTypeLabel noTruncate item={item} />\n                ) : crumb === item ? null : (\n                  <GqlTypeLink\n                    // oxlint-disable-next-line react/no-array-index-key\n                    key={i}\n                    noTruncate\n                    item={crumb}\n                    setItem={setItem}\n                    className=\"!font-sans !text-sm flex-shrink-0\"\n                  />\n                )}\n              </Fragment>\n            );\n          })}\n        </div>\n        <GqlSchemaSearch\n          key={item != null && \"name\" in item.type ? item.type.name : \"search\"} // Force reset when changing items\n          maxHeight={containerHeight}\n          currentItem={item}\n          schema={schema}\n          setItem={(item) => setItem(item)}\n          className=\"hidden @[10rem]:block\"\n        />\n      </div>\n      <div className=\"ml-auto flex gap-1 [&>*]:text-text-subtle\">\n        <IconButton icon=\"x\" size=\"sm\" title=\"Close documentation explorer\" onClick={onClose} />\n      </div>\n    </nav>\n  );\n}\n\nfunction GqlTypeInfo({\n  item,\n  setItem,\n  schema,\n}: {\n  item: ExplorerItem | null;\n  setItem: (t: ExplorerItem) => void;\n  schema: GraphQLSchema;\n}) {\n  if (item == null) return null;\n\n  const description =\n    item.kind === \"type\" ? getNamedType(item.type).description : item.type.description;\n\n  const heading = (\n    <div className=\"mb-3\">\n      <Heading>\n        <GqlTypeLabel item={item} />\n      </Heading>\n      <DocMarkdown>{description || \"No description\"}</DocMarkdown>\n      {\"deprecationReason\" in item.type && item.type.deprecationReason && (\n        <Banner color=\"notice\">\n          <DocMarkdown>{item.type.deprecationReason}</DocMarkdown>\n        </Banner>\n      )}\n    </div>\n  );\n\n  if (isScalarType(item.type)) {\n    return heading;\n  }\n  if (isNonNullType(item.type) || isListType(item.type)) {\n    // kinda a hack, but we'll just unwrap there and show the named type\n    return (\n      <GqlTypeInfo\n        item={toExplorerItem(item.type.ofType, item)}\n        setItem={setItem}\n        schema={schema}\n      />\n    );\n  }\n  if (isInterfaceType(item.type)) {\n    const fields = item.type.getFields();\n    const possibleTypes = schema.getPossibleTypes(item.type) ?? [];\n\n    return (\n      <div>\n        {heading}\n\n        <Subheading count={Object.keys(fields).length}>Fields</Subheading>\n        {Object.entries(fields).map(([fieldName, field]) => {\n          const fieldItem: ExplorerItem = toExplorerItem(field, item);\n          return (\n            <div key={`${String(field.type)}::${field.name}`} className=\"my-4\">\n              <GqlTypeRow\n                item={fieldItem}\n                setItem={setItem}\n                name={{ value: fieldName, color: \"primary\" }}\n              />\n            </div>\n          );\n        })}\n\n        {possibleTypes.length > 0 && (\n          <>\n            <Subheading>Implemented By</Subheading>\n            {possibleTypes.map((t) => (\n              <GqlTypeRow key={t.name} item={toExplorerItem(t, item)} setItem={setItem} />\n            ))}\n          </>\n        )}\n      </div>\n    );\n  }\n  if (isUnionType(item.type)) {\n    const types = item.type.getTypes();\n\n    return (\n      <div>\n        {heading}\n\n        <Subheading>Possible Types</Subheading>\n        {types.map((t) => (\n          <GqlTypeRow key={t.name} item={{ kind: \"type\", type: t, from: item }} setItem={setItem} />\n        ))}\n      </div>\n    );\n  }\n  if (isEnumType(item.type)) {\n    const values = item.type.getValues();\n\n    return (\n      <div>\n        {heading}\n        <Subheading>Values</Subheading>\n        {values.map((v) => (\n          <div key={v.name} className=\"my-4 font-mono text-editor truncate\">\n            <span className=\"text-primary\">{v.value}</span>\n            <DocMarkdown>{v.description ?? null}</DocMarkdown>\n          </div>\n        ))}\n      </div>\n    );\n  }\n  if (item.kind === \"input_field\") {\n    return (\n      <div className=\"flex flex-col gap-3\">\n        {heading}\n\n        {item.type.defaultValue !== undefined && (\n          <div>\n            <Subheading>Default Value</Subheading>\n            <div className=\"font-mono text-editor\">{JSON.stringify(item.type.defaultValue)}</div>\n          </div>\n        )}\n\n        <div>\n          <Subheading>Type</Subheading>\n          <GqlTypeRow\n            className=\"mt-4\"\n            item={{ kind: \"type\", type: item.type.type, from: item }}\n            setItem={setItem}\n          />\n        </div>\n      </div>\n    );\n  }\n  if (item.kind === \"field\") {\n    return (\n      <div className=\"flex flex-col gap-3\">\n        {heading}\n\n        <div>\n          <Subheading>Type</Subheading>\n          <GqlTypeRow\n            className=\"mt-4\"\n            item={{ kind: \"type\", type: item.type.type, from: item }}\n            setItem={setItem}\n          />\n        </div>\n\n        {item.type.args.length > 0 && (\n          <div>\n            <Subheading>Arguments</Subheading>\n            {item.type.args.map((a) => {\n              return (\n                <div key={`${String(a.type)}::${a.name}`} className=\"my-4\">\n                  <GqlTypeRow\n                    name={{ value: a.name, color: \"info\" }}\n                    item={{ kind: \"type\", type: a.type, from: item }}\n                    setItem={setItem}\n                  />\n                </div>\n              );\n            })}\n          </div>\n        )}\n      </div>\n    );\n  }\n  if (isInputObjectType(item.type)) {\n    const fields = item.type.getFields();\n    return (\n      <div>\n        {heading}\n\n        <Subheading count={Object.keys(fields).length}>Fields</Subheading>\n        {Object.keys(fields).map((fieldName) => {\n          const field = fields[fieldName];\n          if (field == null) return null;\n          const fieldItem: ExplorerItem = {\n            kind: \"input_field\",\n            type: field,\n            from: item,\n          };\n          return (\n            <div key={`${String(field.type)}::${field.name}`} className=\"my-4\">\n              <GqlTypeRow\n                item={fieldItem}\n                setItem={setItem}\n                name={{ value: fieldName, color: \"primary\" }}\n              />\n            </div>\n          );\n        })}\n      </div>\n    );\n  }\n  if (isObjectType(item.type)) {\n    const fields = item.type.getFields();\n    const interfaces = item.type.getInterfaces();\n\n    return (\n      <div>\n        {heading}\n        {interfaces.length > 0 && (\n          <>\n            <Subheading>Implements</Subheading>\n            {interfaces.map((i) => (\n              <GqlTypeRow\n                key={i.name}\n                item={{ kind: \"type\", type: i, from: item }}\n                setItem={setItem}\n              />\n            ))}\n          </>\n        )}\n\n        <Subheading count={Object.keys(fields).length}>Fields</Subheading>\n        {Object.keys(fields).map((fieldName) => {\n          const field = fields[fieldName];\n          if (field == null) return null;\n          const fieldItem: ExplorerItem = { kind: \"field\", type: field, from: item };\n          return (\n            <div key={`${String(field.type)}::${field.name}`} className=\"my-4\">\n              <GqlTypeRow\n                item={fieldItem}\n                setItem={setItem}\n                name={{ value: fieldName, color: \"primary\" }}\n              />\n            </div>\n          );\n        })}\n      </div>\n    );\n  }\n\n  console.log(\"Unknown GraphQL Type\", item);\n  return <div>Unknown GraphQL type</div>;\n}\n\nfunction GqlTypeRow({\n  item,\n  setItem,\n  name,\n  description,\n  className,\n  hideDescription,\n}: {\n  item: ExplorerItem;\n  name?: { value: string; color: Color };\n  description?: string | null;\n  setItem: (t: ExplorerItem) => void;\n  className?: string;\n  hideDescription?: boolean;\n}) {\n  if (item == null) return null;\n\n  let child: ReactNode = <>Unknown Type</>;\n\n  if (item.kind === \"type\") {\n    child = (\n      <>\n        <div className=\"font-mono text-editor\">\n          {name && (\n            <span\n              className={classNames(\n                name?.color === \"danger\" && \"text-danger\",\n                name?.color === \"primary\" && \"text-primary\",\n                name?.color === \"success\" && \"text-success\",\n                name?.color === \"warning\" && \"text-warning\",\n                name?.color === \"notice\" && \"text-notice\",\n                name?.color === \"info\" && \"text-info\",\n              )}\n            >\n              {name.value}:&nbsp;\n            </span>\n          )}\n          <GqlTypeLink color=\"notice\" item={item} setItem={setItem} />\n        </div>\n        {!hideDescription && (\n          <DocMarkdown>\n            {(description === undefined ? getNamedType(item.type).description : description) ??\n              null}\n          </DocMarkdown>\n        )}\n      </>\n    );\n  } else if (item.kind === \"field\") {\n    const returnItem: ExplorerItem = {\n      kind: \"type\",\n      type: item.type.type,\n      from: item.from,\n    };\n    child = (\n      <div>\n        <div className=\"font-mono text-editor\">\n          <GqlTypeLink color=\"info\" item={item} setItem={setItem}>\n            {name?.value}\n          </GqlTypeLink>\n          {item.type.args.length > 0 && (\n            <>\n              <span className=\"text-text-subtle\">(</span>\n              {item.type.args.map((arg) => (\n                <div\n                  key={`${String(arg.type)}::${arg.name}`}\n                  className={classNames(item.type.args.length === 1 && \"inline-flex\")}\n                >\n                  {item.type.args.length > 1 && <>&nbsp;&nbsp;</>}\n                  <span className=\"text-primary\">{arg.name}:</span>&nbsp;\n                  <GqlTypeLink\n                    color=\"notice\"\n                    item={{ kind: \"type\", type: arg.type, from: item.from }}\n                    setItem={setItem}\n                  />\n                </div>\n              ))}\n              <span className=\"text-text-subtle\">)</span>\n            </>\n          )}\n          <span className=\"text-text-subtle\">:</span>{\" \"}\n          <GqlTypeLink color=\"notice\" item={returnItem} setItem={setItem} />\n        </div>\n        <DocMarkdown className=\"!text-text-subtle mt-0.5\">\n          {item.type.description ?? null}\n        </DocMarkdown>\n      </div>\n    );\n  } else if (item.kind === \"input_field\") {\n    child = (\n      <>\n        <div className=\"font-mono text-editor\">\n          {name && <span className=\"text-primary\">{name.value}:</span>}{\" \"}\n          <GqlTypeLink color=\"notice\" item={item} setItem={setItem} />\n        </div>\n        <DocMarkdown>{item.type.description ?? null}</DocMarkdown>\n      </>\n    );\n  }\n\n  return <div className={classNames(className, \"w-full min-w-0\")}>{child}</div>;\n}\n\nfunction GqlTypeLink({\n  item,\n  setItem,\n  color,\n  children,\n  leftSlot,\n  rightSlot,\n  onNavigate,\n  className,\n  noTruncate,\n}: {\n  item: ExplorerItem;\n  color?: Color;\n  setItem: (item: ExplorerItem) => void;\n  onNavigate?: () => void;\n  children?: ReactNode;\n  leftSlot?: ReactNode;\n  rightSlot?: ReactNode;\n  className?: string;\n  noTruncate?: boolean;\n}) {\n  if (item?.kind === \"type\" && isListType(item.type)) {\n    return (\n      <span className=\"font-mono text-editor\">\n        <span className=\"text-text-subtle\">[</span>\n        <GqlTypeLink\n          item={{ ...item, type: item.type.ofType }}\n          setItem={setItem}\n          color={color}\n          leftSlot={leftSlot}\n          rightSlot={rightSlot}\n        >\n          {children}\n        </GqlTypeLink>\n        <span className=\"text-text-subtle\">]</span>\n      </span>\n    );\n  }\n  if (item?.kind === \"type\" && isNonNullType(item.type)) {\n    return (\n      <span className=\"font-mono text-editor\">\n        <GqlTypeLink\n          item={{ ...item, type: item.type.ofType }}\n          setItem={setItem}\n          color={color}\n          leftSlot={leftSlot}\n          rightSlot={rightSlot}\n        >\n          {children}\n        </GqlTypeLink>\n        <span className=\"text-text-subtle\">!</span>\n      </span>\n    );\n  }\n\n  return (\n    <button\n      type=\"button\"\n      className={classNames(\n        className,\n        \"hover:underline text-left mr-auto gap-2 max-w-full\",\n        \"inline-flex items-center\",\n        \"font-mono text-editor\",\n        !noTruncate && \"truncate\",\n        color === \"danger\" && \"text-danger\",\n        color === \"primary\" && \"text-primary\",\n        color === \"success\" && \"text-success\",\n        color === \"warning\" && \"text-warning\",\n        color === \"notice\" && \"text-notice\",\n        color === \"info\" && \"text-info\",\n      )}\n      onClick={() => {\n        setItem(item);\n        onNavigate?.();\n      }}\n    >\n      {leftSlot}\n      <GqlTypeLabel item={item} noTruncate={noTruncate}>\n        {children}\n      </GqlTypeLabel>\n      {rightSlot}\n    </button>\n  );\n}\n\nfunction GqlTypeLabel({\n  item,\n  children,\n  className,\n  noTruncate,\n}: {\n  item: ExplorerItem;\n  children?: ReactNode;\n  className?: string;\n  noTruncate?: boolean;\n}) {\n  let inner: ReactNode;\n  if (children) {\n    inner = children;\n  } else if (item == null) {\n    inner = \"Root\";\n  } else if (item.kind === \"field\") {\n    inner = item.type.name + (item.type.args.length > 0 ? \"(…)\" : \"\");\n  } else if (\"name\" in item.type) {\n    inner = item.type.name;\n  } else {\n    console.error(\"Unknown item type\", item);\n    inner = \"UNKNOWN\";\n  }\n\n  return <span className={classNames(className, !noTruncate && \"truncate\")}>{inner}</span>;\n}\n\nfunction Subheading({ children, count }: { children: ReactNode; count?: number }) {\n  return (\n    <h2 className=\"font-bold text-lg mt-6 flex items-center\">\n      <div className=\"truncate min-w-0\">{children}</div>\n      {count && <CountBadge count={count} />}\n    </h2>\n  );\n}\n\ninterface SearchResult {\n  name: string;\n  // oxlint-disable-next-line no-explicit-any\n  type: GraphQLNamedType | GraphQLField<any, any> | GraphQLInputField;\n  score: number;\n  from: GraphQLNamedType | null;\n  depth: string[];\n}\n\nfunction GqlSchemaSearch({\n  schema,\n  currentItem,\n  setItem,\n  className,\n  maxHeight,\n}: {\n  currentItem: ExplorerItem | null;\n  schema: GraphQLSchema;\n  setItem: (t: ExplorerItem) => void;\n  className?: string;\n  maxHeight: number;\n}) {\n  const [activeResult, setActiveResult] = useStateWithDeps<SearchResult | null>(null, [\n    currentItem,\n  ]);\n  const [focused, setOpen] = useState<boolean>(false);\n  const [value, setValue] = useState<string>(\"\");\n  const debouncedValue = useDebouncedValue(value, 300);\n  const menuRef = useRef<HTMLDivElement>(null);\n  const canSearch =\n    currentItem == null ||\n    (isNamedType(currentItem.type) &&\n      !isEnumType(currentItem.type) &&\n      !isScalarType(currentItem.type));\n\n  const results = useMemo(() => {\n    const results: SearchResult[] = [];\n    walkTypeGraph(schema, currentItem?.type ?? null, (type, from, depth) => {\n      if (type === currentItem?.type) {\n        return true; // Skip the current type and continue\n      }\n\n      const match = fuzzyMatch(type.name, debouncedValue);\n      if (match == null) {\n        // Do nothing\n      } else {\n        results.push({ name: type.name, type, score: match.score, from, depth });\n      }\n      return true;\n    });\n    results.sort((a, b) => {\n      if (value === \"\") {\n        if (a.name.startsWith(\"_\") && !b.name.startsWith(\"_\")) {\n          // Always sort __<NAME> types to the end when there is no query\n          return 1;\n        }\n        if (a.depth.length !== b.depth.length) {\n          return a.depth.length - b.depth.length;\n        }\n        return a.name.localeCompare(b.name);\n      }\n      if (a.depth.length !== b.depth.length) {\n        return a.depth.length - b.depth.length;\n      }\n      if (a.score === 0 && b.score === 0) {\n        return a.name.localeCompare(b.name);\n      }\n      if (a.score === b.score && a.name.length === b.name.length) {\n        return a.name.localeCompare(b.name);\n      }\n      if (a.score === b.score) {\n        return a.name.length - b.type.name.length;\n      }\n      return b.score - a.score;\n    });\n    return results.slice(0, 100);\n  }, [currentItem, schema, debouncedValue, value]);\n\n  const activeIndex = useMemo(() => {\n    const index = (activeResult ? results.indexOf(activeResult) : 0) ?? 0;\n    return index === -1 ? 0 : index;\n  }, [activeResult, results]);\n\n  const inputRef = useRef<HTMLInputElement>(null);\n  useClickOutside(menuRef, () => setOpen(false));\n\n  const handleKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLInputElement>) => {\n      if (e.key === \"ArrowDown\" || (e.ctrlKey && e.key === \"n\")) {\n        e.preventDefault();\n        const next = results[activeIndex + 1] ?? results[results.length - 1] ?? null;\n        setActiveResult(next);\n      } else if (e.key === \"ArrowUp\" || (e.ctrlKey && e.key === \"k\")) {\n        e.preventDefault();\n        const prev = results[activeIndex - 1] ?? results[0] ?? null;\n        setActiveResult(prev);\n      } else if (e.key === \"Escape\") {\n        inputRef.current?.blur();\n      } else if (e.key === \"Enter\") {\n        const result = activeResult ?? results[0] ?? null;\n        if (result) {\n          setItem(toExplorerItem(result?.type, currentItem));\n          inputRef.current?.blur();\n        }\n      }\n    },\n    [results, activeIndex, setActiveResult, activeResult, setItem, currentItem],\n  );\n\n  if (!canSearch) return <span />;\n\n  return (\n    <div\n      className={classNames(\n        className,\n        \"relative flex items-center bg-surface z-20 min-w-0\",\n        !focused && \"max-w-[6rem] ml-auto\",\n        focused && \"!absolute top-0 left-1.5 right-1.5 bottom-0 pt-1.5\",\n      )}\n    >\n      <PlainInput\n        ref={inputRef}\n        size=\"sm\"\n        label=\"search\"\n        hideLabel\n        defaultValue={value}\n        placeholder={\n          focused\n            ? `Search ${currentItem != null && \"name\" in currentItem.type ? currentItem.type.name : \"Schema\"}`\n            : \"Search\"\n        }\n        leftSlot={\n          <div className=\"w-10 flex justify-center items-center\">\n            <Icon size=\"sm\" icon=\"search\" color=\"secondary\" />\n          </div>\n        }\n        onChange={setValue}\n        onKeyDownCapture={handleKeyDown}\n        onFocus={() => {\n          setOpen(true);\n        }}\n      />\n      <div\n        ref={menuRef}\n        style={{ maxHeight: maxHeight - 60 }}\n        className={classNames(\n          \"x-theme-menu absolute z-10 mt-0.5 p-1.5 top-full right-0 bg-surface\",\n          \"border border-border rounded-lg overflow-y-auto w-full shadow-lg\",\n          !focused && \"hidden\",\n        )}\n      >\n        {results.length === 0 && (\n          <SearchResult isActive={false} className=\"text-text-subtle\">\n            No results found\n          </SearchResult>\n        )}\n        {results.map((r, i) => {\n          const item = toExplorerItem(r.type, currentItem);\n          if (item === currentItem) return null;\n          return (\n            <SearchResult\n              key={`${i}::${r.type.name}`}\n              onMouseDown={() => {\n                setItem(item);\n                setOpen(false);\n              }}\n              onMouseEnter={() => setActiveResult(r)}\n              isActive={i === activeIndex}\n            >\n              {r.from !== currentItem?.type && r.from != null && (\n                <>\n                  <GqlTypeLabel\n                    item={toExplorerItem(r.from, currentItem)}\n                    className=\"text-text-subtle\"\n                  />\n                  .\n                </>\n              )}\n              <GqlTypeLabel item={item} className=\"text-text\" />\n            </SearchResult>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\nfunction SearchResult({\n  isActive,\n  className,\n  ...extraProps\n}: {\n  isActive: boolean;\n  children: ReactNode;\n} & HTMLAttributes<HTMLButtonElement>) {\n  const initRef = useCallback(\n    (el: HTMLButtonElement | null) => {\n      if (el === null) return;\n      if (isActive) {\n        el.scrollIntoView({ block: \"nearest\", inline: \"nearest\", behavior: \"smooth\" });\n      }\n    },\n    [isActive],\n  );\n  return (\n    <button\n      ref={initRef}\n      className={classNames(\n        className,\n        \"px-3 truncate w-full text-left h-sm rounded text-editor font-mono\",\n        isActive && \"bg-surface-highlight\",\n      )}\n      {...extraProps}\n    />\n  );\n}\n\nfunction Heading({ children }: { children: ReactNode }) {\n  return <h1 className=\"font-bold text-2xl truncate\">{children}</h1>;\n}\n\nfunction DocMarkdown({ children, className }: { children: string | null; className?: string }) {\n  return (\n    <Markdown className={classNames(className, \"!text-text-subtle italic\")}>{children}</Markdown>\n  );\n}\n\nfunction walkTypeGraph(\n  schema: GraphQLSchema,\n  // oxlint-disable-next-line no-explicit-any\n  start: GraphQLType | GraphQLField<any, any> | GraphQLInputField | null,\n  cb: (\n    // oxlint-disable-next-line no-explicit-any\n    type: GraphQLNamedType | GraphQLField<any, any> | GraphQLInputField,\n    from: GraphQLNamedType | null,\n    path: string[],\n  ) => boolean,\n) {\n  const visited = new Set<string>();\n  const queue: Array<{\n    // oxlint-disable-next-line no-explicit-any\n    current: GraphQLType | GraphQLField<any, any> | GraphQLInputField;\n    from: GraphQLNamedType | null;\n    path: string[];\n  }> = [];\n\n  const initial = start\n    ? [start]\n    : [\n        ...Object.values(schema.getTypeMap()),\n        schema.getQueryType(),\n        schema.getMutationType(),\n        schema.getSubscriptionType(),\n      ].filter((t) => t != null);\n\n  for (const type of initial) {\n    queue.push({ current: type, from: null, path: [] });\n  }\n\n  while (queue.length > 0) {\n    // oxlint-disable-next-line no-non-null-assertion\n    const { current, from, path } = queue.shift()!;\n    if (!isNamedType(current)) continue;\n\n    const name = current.name;\n    if (visited.has(name)) continue;\n    visited.add(name);\n\n    const cont = cb(current, from, path);\n    if (!cont) break;\n\n    if (isObjectType(current) || isInterfaceType(current)) {\n      for (const field of Object.values(current.getFields())) {\n        cb(field, current, [...path, current.name]);\n\n        const fieldType = getNamedType(field.type);\n        const next = schema.getType(fieldType.name);\n        if (next && !visited.has(fieldType.name)) {\n          queue.push({\n            current: next,\n            from: current,\n            path: [...path, current.name, field.name],\n          });\n        }\n      }\n    } else if (isInputObjectType(current)) {\n      for (const inputField of Object.values(current.getFields())) {\n        cb(inputField, current, [...path, current.name]);\n\n        const fieldType = getNamedType(inputField.type);\n        const next = schema.getType(fieldType.name);\n        if (next && !visited.has(fieldType.name)) {\n          queue.push({\n            current: next,\n            from: current,\n            path: [...path, current.name, inputField.name],\n          });\n        }\n      }\n    } else if (isUnionType(current)) {\n      for (const subtype of current.getTypes()) {\n        if (!visited.has(subtype.name)) {\n          queue.push({\n            current: subtype,\n            from: current,\n            path: [...path, current.name, subtype.name],\n          });\n        }\n      }\n    }\n  }\n}\n\n// oxlint-disable-next-line no-explicit-any\nfunction toExplorerItem(t: any, from: ExplorerItem | null): ExplorerItem | null {\n  if (t == null) return null;\n\n  // GraphQLField-like: has `args` (array) and `type`\n  if (typeof t === \"object\" && Array.isArray(t.args) && t.type) {\n    return { kind: \"field\", type: t, from };\n  }\n\n  // GraphQLInputField-like: has `type`, no `args`, maybe `defaultValue`, and no `resolve`\n  if (\n    typeof t === \"object\" &&\n    t.type &&\n    !(\"args\" in t) &&\n    !(\"resolve\" in t) &&\n    (\"defaultValue\" in t || \"description\" in t)\n  ) {\n    return { kind: \"input_field\", type: t, from };\n  }\n\n  // Fallback: treat as GraphQLNamedType (object, scalar, enum, etc.)\n  return { kind: \"type\", type: t, from };\n}\n"
  },
  {
    "path": "src-web/components/graphql/GraphQLEditor.tsx",
    "content": "import type { HttpRequest } from \"@yaakapp-internal/models\";\n\nimport { useAtom } from \"jotai\";\nimport { useCallback, useMemo } from \"react\";\nimport { useLocalStorage } from \"react-use\";\nimport { useIntrospectGraphQL } from \"../../hooks/useIntrospectGraphQL\";\nimport { useStateWithDeps } from \"../../hooks/useStateWithDeps\";\nimport { showDialog } from \"../../lib/dialog\";\nimport { Banner } from \"../core/Banner\";\nimport { Button } from \"../core/Button\";\nimport type { DropdownItem } from \"../core/Dropdown\";\nimport { Dropdown } from \"../core/Dropdown\";\nimport type { EditorProps } from \"../core/Editor/Editor\";\nimport { Editor } from \"../core/Editor/LazyEditor\";\nimport { FormattedError } from \"../core/FormattedError\";\nimport { Icon } from \"../core/Icon\";\nimport { Separator } from \"../core/Separator\";\nimport { tryFormatGraphql } from \"../../lib/formatters\";\nimport { showGraphQLDocExplorerAtom } from \"./graphqlAtoms\";\n\ntype Props = Pick<EditorProps, \"heightMode\" | \"className\" | \"forceUpdateKey\"> & {\n  baseRequest: HttpRequest;\n  onChange: (body: HttpRequest[\"body\"]) => void;\n  request: HttpRequest;\n};\n\nexport function GraphQLEditor(props: Props) {\n  // There's some weirdness with stale onChange being called when switching requests, so we'll\n  // key on the request ID as a workaround for now.\n  return <GraphQLEditorInner key={props.request.id} {...props} />;\n}\n\nfunction GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProps }: Props) {\n  const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<\n    Record<string, boolean>\n  >(\"graphQLAutoIntrospectDisabled\", {});\n  const { schema, isLoading, error, refetch, clear } = useIntrospectGraphQL(baseRequest, {\n    disabled: autoIntrospectDisabled?.[baseRequest.id],\n  });\n  const [currentBody, setCurrentBody] = useStateWithDeps<{\n    query: string;\n    variables: string | undefined;\n  }>(() => {\n    // Migrate text bodies to GraphQL format\n    // NOTE: This is how GraphQL used to be stored\n    if (\"text\" in request.body) {\n      const b = tryParseJson(request.body.text, {});\n      const variables = JSON.stringify(b.variables || undefined, null, 2);\n      return { query: b.query ?? \"\", variables };\n    }\n\n    return { query: request.body.query ?? \"\", variables: request.body.variables ?? \"\" };\n  }, [extraEditorProps.forceUpdateKey]);\n\n  const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);\n  const isDocOpen = isDocOpenRecord[request.id] !== undefined;\n\n  const handleChangeQuery = useCallback(\n    (query: string) => {\n      setCurrentBody(({ variables }) => {\n        const newBody = { query, variables };\n        onChange(newBody);\n        return newBody;\n      });\n    },\n    [onChange, setCurrentBody],\n  );\n\n  const handleChangeVariables = useCallback(\n    (variables: string) => {\n      setCurrentBody(({ query }) => {\n        const newBody = { query, variables: variables || undefined };\n        onChange(newBody);\n        return newBody;\n      });\n    },\n    [onChange, setCurrentBody],\n  );\n\n  const actions = useMemo<EditorProps[\"actions\"]>(\n    () => [\n      <div key=\"actions\" className=\"flex flex-row !opacity-100 !shadow\">\n        <div key=\"introspection\" className=\"!opacity-100\">\n          {schema === undefined ? null /* Initializing */ : (\n            <Dropdown\n              items={[\n                ...((schema != null\n                  ? [\n                      {\n                        label: \"Clear\",\n                        onSelect: clear,\n                        color: \"danger\",\n                        leftSlot: <Icon icon=\"trash\" />,\n                      },\n                      { type: \"separator\" },\n                    ]\n                  : []) satisfies DropdownItem[]),\n                {\n                  hidden: !error,\n                  label: (\n                    <Banner color=\"danger\">\n                      <p className=\"mb-1\">Schema introspection failed</p>\n                      <Button\n                        size=\"xs\"\n                        color=\"danger\"\n                        variant=\"border\"\n                        onClick={() => {\n                          showDialog({\n                            title: \"Introspection Failed\",\n                            size: \"sm\",\n                            id: \"introspection-failed\",\n                            render: ({ hide }) => (\n                              <>\n                                <FormattedError>{error ?? \"unknown\"}</FormattedError>\n                                <div className=\"w-full my-4\">\n                                  <Button\n                                    onClick={async () => {\n                                      hide();\n                                      await refetch();\n                                    }}\n                                    className=\"ml-auto\"\n                                    color=\"primary\"\n                                    size=\"sm\"\n                                  >\n                                    Retry Request\n                                  </Button>\n                                </div>\n                              </>\n                            ),\n                          });\n                        }}\n                      >\n                        View Error\n                      </Button>\n                    </Banner>\n                  ),\n                  type: \"content\",\n                },\n                {\n                  hidden: schema == null,\n                  label: `${isDocOpen ? \"Hide\" : \"Show\"} Documentation`,\n                  leftSlot: <Icon icon=\"book_open_text\" />,\n                  onSelect: () => {\n                    setGraphqlDocStateAtomValue((v) => ({\n                      ...v,\n                      [request.id]: isDocOpen ? undefined : null,\n                    }));\n                  },\n                },\n                {\n                  label: \"Introspect Schema\",\n                  leftSlot: <Icon icon=\"refresh\" spin={isLoading} />,\n                  keepOpenOnSelect: true,\n                  onSelect: refetch,\n                },\n                { type: \"separator\", label: \"Setting\" },\n                {\n                  label: \"Automatic Introspection\",\n                  keepOpenOnSelect: true,\n                  onSelect: () => {\n                    setAutoIntrospectDisabled({\n                      ...autoIntrospectDisabled,\n                      [baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id],\n                    });\n                  },\n                  leftSlot: (\n                    <Icon\n                      icon={\n                        autoIntrospectDisabled?.[baseRequest.id]\n                          ? \"check_square_unchecked\"\n                          : \"check_square_checked\"\n                      }\n                    />\n                  ),\n                },\n              ]}\n            >\n              <Button\n                size=\"sm\"\n                variant=\"border\"\n                title=\"Refetch Schema\"\n                isLoading={isLoading}\n                color={error ? \"danger\" : \"default\"}\n                forDropdown\n              >\n                {error ? \"Introspection Failed\" : schema ? \"Schema\" : \"No Schema\"}\n              </Button>\n            </Dropdown>\n          )}\n        </div>\n      </div>,\n    ],\n    [\n      schema,\n      clear,\n      error,\n      isDocOpen,\n      isLoading,\n      refetch,\n      autoIntrospectDisabled,\n      baseRequest.id,\n      setGraphqlDocStateAtomValue,\n      request.id,\n      setAutoIntrospectDisabled,\n    ],\n  );\n\n  return (\n    <div className=\"h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto]\">\n      <Editor\n        language=\"graphql\"\n        heightMode=\"auto\"\n        graphQLSchema={schema}\n        format={tryFormatGraphql}\n        defaultValue={currentBody.query}\n        onChange={handleChangeQuery}\n        placeholder=\"...\"\n        actions={actions}\n        stateKey={`graphql_body.${request.id}`}\n        {...extraEditorProps}\n      />\n      <div className=\"grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 min-h-[5rem]\">\n        <Separator dashed className=\"pb-1\">\n          Variables\n        </Separator>\n        <Editor\n          language=\"json\"\n          heightMode=\"auto\"\n          defaultValue={currentBody.variables}\n          onChange={handleChangeVariables}\n          placeholder=\"{}\"\n          stateKey={`graphql_vars.${request.id}`}\n          autocompleteFunctions\n          autocompleteVariables\n          {...extraEditorProps}\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction tryParseJson(text: string, fallback: unknown) {\n  try {\n    return JSON.parse(text);\n  } catch {\n    return fallback;\n  }\n}\n"
  },
  {
    "path": "src-web/components/graphql/graphqlAtoms.ts",
    "content": "import { atomWithKVStorage } from \"../../lib/atoms/atomWithKVStorage\";\n\nexport const showGraphQLDocExplorerAtom = atomWithKVStorage<\n  Record<\n    string,\n    | {\n        type?: string;\n        field?: string;\n        parentType?: string;\n      }\n    | null\n    | undefined\n  >\n>(\"show_graphql_docs\", {});\n"
  },
  {
    "path": "src-web/components/responseViewers/AudioViewer.tsx",
    "content": "import { convertFileSrc } from \"@tauri-apps/api/core\";\nimport { useEffect, useState } from \"react\";\n\ninterface Props {\n  bodyPath?: string;\n  data?: Uint8Array;\n}\n\nexport function AudioViewer({ bodyPath, data }: Props) {\n  const [src, setSrc] = useState<string>();\n\n  useEffect(() => {\n    if (bodyPath) {\n      setSrc(convertFileSrc(bodyPath));\n    } else if (data) {\n      const blob = new Blob([new Uint8Array(data)], { type: \"audio/mpeg\" });\n      const url = URL.createObjectURL(blob);\n      setSrc(url);\n      return () => URL.revokeObjectURL(url);\n    } else {\n      setSrc(undefined);\n    }\n  }, [bodyPath, data]);\n\n  // oxlint-disable-next-line jsx-a11y/media-has-caption\n  return <audio className=\"w-full\" controls src={src} />;\n}\n"
  },
  {
    "path": "src-web/components/responseViewers/BinaryViewer.tsx",
    "content": "import type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { useSaveResponse } from \"../../hooks/useSaveResponse\";\nimport { getContentTypeFromHeaders } from \"../../lib/model_util\";\nimport { Banner } from \"../core/Banner\";\nimport { Button } from \"../core/Button\";\nimport { InlineCode } from \"../core/InlineCode\";\nimport { LoadingIcon } from \"../core/LoadingIcon\";\nimport { EmptyStateText } from \"../EmptyStateText\";\n\ninterface Props {\n  response: HttpResponse;\n}\n\nexport function BinaryViewer({ response }: Props) {\n  const saveResponse = useSaveResponse(response);\n  const contentType = getContentTypeFromHeaders(response.headers) ?? \"unknown\";\n\n  // Wait until the response has been fully-downloaded\n  if (response.state !== \"closed\") {\n    return (\n      <EmptyStateText>\n        <LoadingIcon size=\"sm\" />\n      </EmptyStateText>\n    );\n  }\n\n  return (\n    <Banner color=\"primary\" className=\"h-full flex flex-col gap-3\">\n      <p>\n        Content type <InlineCode>{contentType}</InlineCode> cannot be previewed\n      </p>\n      <div>\n        <Button variant=\"border\" size=\"sm\" onClick={() => saveResponse.mutate()}>\n          Save to File\n        </Button>\n      </div>\n    </Banner>\n  );\n}\n"
  },
  {
    "path": "src-web/components/responseViewers/CsvViewer.tsx",
    "content": "import classNames from \"classnames\";\nimport Papa from \"papaparse\";\nimport { useMemo } from \"react\";\nimport { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from \"../core/Table\";\n\ninterface Props {\n  text: string | null;\n  className?: string;\n}\n\nexport function CsvViewer({ text, className }: Props) {\n  return (\n    <div className=\"overflow-auto h-full\">\n      <CsvViewerInner text={text} className={className} />\n    </div>\n  );\n}\n\nexport function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {\n  const parsed = useMemo(() => {\n    if (text == null) return null;\n    return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });\n  }, [text]);\n\n  if (parsed === null) return null;\n\n  return (\n    <div className=\"overflow-auto h-full\">\n      <Table className={classNames(className, \"text-sm\")}>\n        <TableHead>\n          <TableRow>\n            {parsed.meta.fields?.map((field) => (\n              <TableHeaderCell key={field}>{field}</TableHeaderCell>\n            ))}\n          </TableRow>\n        </TableHead>\n        <TableBody>\n          {parsed.data.map((row, i) => (\n            // oxlint-disable-next-line react/no-array-index-key\n            <TableRow key={i}>\n              {parsed.meta.fields?.map((key) => (\n                <TableCell key={key}>{row[key] ?? \"\"}</TableCell>\n              ))}\n            </TableRow>\n          ))}\n        </TableBody>\n      </Table>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/responseViewers/EventStreamViewer.tsx",
    "content": "import type { HttpResponse } from \"@yaakapp-internal/models\";\nimport type { ServerSentEvent } from \"@yaakapp-internal/sse\";\nimport classNames from \"classnames\";\nimport { Fragment, useMemo, useState } from \"react\";\nimport { useFormatText } from \"../../hooks/useFormatText\";\nimport { useResponseBodyEventSource } from \"../../hooks/useResponseBodyEventSource\";\nimport { isJSON } from \"../../lib/contentType\";\nimport { Button } from \"../core/Button\";\nimport type { EditorProps } from \"../core/Editor/Editor\";\nimport { Editor } from \"../core/Editor/LazyEditor\";\nimport { EventDetailHeader, EventViewer } from \"../core/EventViewer\";\nimport { EventViewerRow } from \"../core/EventViewerRow\";\nimport { Icon } from \"../core/Icon\";\nimport { InlineCode } from \"../core/InlineCode\";\nimport { HStack, VStack } from \"../core/Stacks\";\n\ninterface Props {\n  response: HttpResponse;\n}\n\nexport function EventStreamViewer({ response }: Props) {\n  return (\n    <Fragment\n      key={response.id} // force a refresh when the response changes\n    >\n      <ActualEventStreamViewer response={response} />\n    </Fragment>\n  );\n}\n\nfunction ActualEventStreamViewer({ response }: Props) {\n  const [showLarge, setShowLarge] = useState<boolean>(false);\n  const [showingLarge, setShowingLarge] = useState<boolean>(false);\n  const events = useResponseBodyEventSource(response);\n\n  return (\n    <EventViewer\n      events={events.data ?? []}\n      getEventKey={(_, index) => String(index)}\n      error={events.error ? String(events.error) : null}\n      splitLayoutName=\"sse_events\"\n      defaultRatio={0.4}\n      renderRow={({ event, index, isActive, onClick }) => (\n        <EventViewerRow\n          isActive={isActive}\n          onClick={onClick}\n          icon={<Icon color=\"info\" title=\"Server Message\" icon=\"arrow_big_down_dash\" />}\n          content={\n            <HStack space={2} className=\"items-center\">\n              <EventLabels event={event} index={index} isActive={isActive} />\n              <span className=\"truncate text-xs\">{event.data.slice(0, 1000)}</span>\n            </HStack>\n          }\n        />\n      )}\n      renderDetail={({ event, index, onClose }) => (\n        <EventDetail\n          event={event}\n          index={index}\n          showLarge={showLarge}\n          showingLarge={showingLarge}\n          setShowLarge={setShowLarge}\n          setShowingLarge={setShowingLarge}\n          onClose={onClose}\n        />\n      )}\n    />\n  );\n}\n\nfunction EventDetail({\n  event,\n  index,\n  showLarge,\n  showingLarge,\n  setShowLarge,\n  setShowingLarge,\n  onClose,\n}: {\n  event: ServerSentEvent;\n  index: number;\n  showLarge: boolean;\n  showingLarge: boolean;\n  setShowLarge: (v: boolean) => void;\n  setShowingLarge: (v: boolean) => void;\n  onClose: () => void;\n}) {\n  const language = useMemo<\"text\" | \"json\">(() => {\n    if (!event?.data) return \"text\";\n    return isJSON(event?.data) ? \"json\" : \"text\";\n  }, [event?.data]);\n\n  return (\n    <div className=\"flex flex-col h-full\">\n      <EventDetailHeader\n        title=\"Message Received\"\n        prefix={<EventLabels event={event} index={index} />}\n        onClose={onClose}\n      />\n      {!showLarge && event.data.length > 1000 * 1000 ? (\n        <VStack space={2} className=\"italic text-text-subtlest\">\n          Message previews larger than 1MB are hidden\n          <div>\n            <Button\n              onClick={() => {\n                setShowingLarge(true);\n                setTimeout(() => {\n                  setShowLarge(true);\n                  setShowingLarge(false);\n                }, 500);\n              }}\n              isLoading={showingLarge}\n              color=\"secondary\"\n              variant=\"border\"\n              size=\"xs\"\n            >\n              Try Showing\n            </Button>\n          </div>\n        </VStack>\n      ) : (\n        <FormattedEditor language={language} text={event.data} />\n      )}\n    </div>\n  );\n}\n\nfunction FormattedEditor({ text, language }: { text: string; language: EditorProps[\"language\"] }) {\n  const formatted = useFormatText({ text, language, pretty: true });\n  if (formatted == null) return null;\n  return <Editor readOnly defaultValue={formatted} language={language} stateKey={null} />;\n}\n\nfunction EventLabels({\n  className,\n  event,\n  index,\n  isActive,\n}: {\n  event: ServerSentEvent;\n  index: number;\n  className?: string;\n  isActive?: boolean;\n}) {\n  return (\n    <HStack space={1.5} alignItems=\"center\" className={className}>\n      <InlineCode className={classNames(\"py-0\", isActive && \"bg-text-subtlest text-text\")}>\n        {event.id ?? index}\n      </InlineCode>\n      {event.eventType && (\n        <InlineCode className={classNames(\"py-0\", isActive && \"bg-text-subtlest text-text\")}>\n          {event.eventType}\n        </InlineCode>\n      )}\n    </HStack>\n  );\n}\n"
  },
  {
    "path": "src-web/components/responseViewers/HTMLOrTextViewer.tsx",
    "content": "import type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { useMemo, useState } from \"react\";\nimport { useResponseBodyText } from \"../../hooks/useResponseBodyText\";\nimport { languageFromContentType } from \"../../lib/contentType\";\nimport { getContentTypeFromHeaders } from \"../../lib/model_util\";\nimport type { EditorProps } from \"../core/Editor/Editor\";\nimport { EmptyStateText } from \"../EmptyStateText\";\nimport { TextViewer } from \"./TextViewer\";\nimport { WebPageViewer } from \"./WebPageViewer\";\n\ninterface Props {\n  response: HttpResponse;\n  pretty: boolean;\n  textViewerClassName?: string;\n}\n\nexport function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) {\n  const rawTextBody = useResponseBodyText({ response, filter: null });\n  const contentType = getContentTypeFromHeaders(response.headers);\n  const language = languageFromContentType(contentType, rawTextBody.data ?? \"\");\n\n  if (rawTextBody.isLoading || response.state === \"initialized\") {\n    return null;\n  }\n\n  if (language === \"html\" && pretty) {\n    return <WebPageViewer html={rawTextBody.data ?? \"\"} baseUrl={response.url} />;\n  }\n  if (rawTextBody.data == null) {\n    return <EmptyStateText>Empty response</EmptyStateText>;\n  }\n  return (\n    <HttpTextViewer\n      response={response}\n      text={rawTextBody.data}\n      language={language}\n      pretty={pretty}\n      className={textViewerClassName}\n    />\n  );\n}\n\ninterface HttpTextViewerProps {\n  response: HttpResponse;\n  text: string;\n  language: EditorProps[\"language\"];\n  pretty: boolean;\n  className?: string;\n}\n\nfunction HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {\n  const [currentFilter, setCurrentFilter] = useState<string | null>(null);\n  const filteredBody = useResponseBodyText({ response, filter: currentFilter });\n\n  const filterCallback = useMemo(\n    () => (filter: string) => {\n      setCurrentFilter(filter);\n      return {\n        data: filteredBody.data,\n        isPending: filteredBody.isPending,\n        error: !!filteredBody.error,\n      };\n    },\n    [filteredBody],\n  );\n\n  return (\n    <TextViewer\n      text={text}\n      language={language}\n      stateKey={`response.body.${response.id}`}\n      pretty={pretty}\n      className={className}\n      onFilter={filterCallback}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/responseViewers/ImageViewer.tsx",
    "content": "import { convertFileSrc } from \"@tauri-apps/api/core\";\nimport classNames from \"classnames\";\nimport { useEffect, useState } from \"react\";\n\ntype Props = { className?: string } & (\n  | {\n      bodyPath: string;\n    }\n  | {\n      data: ArrayBuffer;\n    }\n);\n\nexport function ImageViewer({ className, ...props }: Props) {\n  const [src, setSrc] = useState<string>();\n  const bodyPath = \"bodyPath\" in props ? props.bodyPath : null;\n  const data = \"data\" in props ? props.data : null;\n\n  useEffect(() => {\n    if (bodyPath != null) {\n      setSrc(convertFileSrc(bodyPath));\n    } else if (data != null) {\n      const blob = new Blob([data], { type: \"image/png\" });\n      const url = URL.createObjectURL(blob);\n      setSrc(url);\n      return () => URL.revokeObjectURL(url);\n    } else {\n      setSrc(undefined);\n    }\n  }, [bodyPath, data]);\n\n  return (\n    <img\n      src={src}\n      alt=\"Response preview\"\n      className={classNames(className, \"max-w-full max-h-full\")}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/components/responseViewers/JsonViewer.tsx",
    "content": "import classNames from \"classnames\";\nimport { JsonAttributeTree } from \"../core/JsonAttributeTree\";\n\ninterface Props {\n  text: string;\n  className?: string;\n}\n\nexport function JsonViewer({ text, className }: Props) {\n  let parsed = {};\n  try {\n    parsed = JSON.parse(text);\n  } catch {\n    // Nothing yet\n  }\n\n  return (\n    <div className={classNames(className, \"overflow-x-auto h-full\")}>\n      <JsonAttributeTree attrValue={parsed} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/responseViewers/MultipartViewer.tsx",
    "content": "import { type MultipartPart, parseMultipart } from \"@mjackson/multipart-parser\";\nimport { lazy, Suspense, useMemo } from \"react\";\nimport { languageFromContentType } from \"../../lib/contentType\";\nimport { Banner } from \"../core/Banner\";\nimport { Icon } from \"../core/Icon\";\nimport { LoadingIcon } from \"../core/LoadingIcon\";\nimport { TabContent, Tabs } from \"../core/Tabs/Tabs\";\nimport { AudioViewer } from \"./AudioViewer\";\nimport { CsvViewer } from \"./CsvViewer\";\nimport { ImageViewer } from \"./ImageViewer\";\nimport { SvgViewer } from \"./SvgViewer\";\nimport { TextViewer } from \"./TextViewer\";\nimport { VideoViewer } from \"./VideoViewer\";\nimport { WebPageViewer } from \"./WebPageViewer\";\n\nconst PdfViewer = lazy(() => import(\"./PdfViewer\").then((m) => ({ default: m.PdfViewer })));\n\ninterface Props {\n  data: Uint8Array;\n  boundary: string;\n  idPrefix?: string;\n}\n\nexport function MultipartViewer({ data, boundary, idPrefix = \"multipart\" }: Props) {\n  const parseResult = useMemo(() => {\n    try {\n      const maxFileSize = 1024 * 1024 * 10; // 10MB\n      const parsed = parseMultipart(data, { boundary, maxFileSize });\n      const parts = Array.from(parsed);\n      return { parts, error: null };\n    } catch (err) {\n      return { parts: [], error: err instanceof Error ? err.message : String(err) };\n    }\n  }, [data, boundary]);\n\n  const { parts, error } = parseResult;\n\n  if (error) {\n    return (\n      <Banner color=\"danger\" className=\"m-3\">\n        Failed to parse multipart data: {error}\n      </Banner>\n    );\n  }\n\n  if (parts.length === 0) {\n    return (\n      <Banner color=\"info\" className=\"m-3\">\n        No multipart parts found\n      </Banner>\n    );\n  }\n\n  return (\n    <Tabs\n      addBorders\n      label=\"Multipart\"\n      layout=\"horizontal\"\n      tabListClassName=\"border-r border-r-border -ml-3\"\n      tabs={parts.map((part, i) => ({\n        label: part.name ?? \"\",\n        value: tabValue(part, i),\n        rightSlot:\n          part.filename && part.headers.contentType.mediaType?.startsWith(\"image/\") ? (\n            <div className=\"h-5 w-5 overflow-auto flex items-center justify-end\">\n              <ImageViewer\n                data={part.arrayBuffer}\n                className=\"ml-auto w-auto rounded overflow-hidden\"\n              />\n            </div>\n          ) : part.filename ? (\n            <Icon icon=\"file\" />\n          ) : null,\n      }))}\n    >\n      {parts.map((part, i) => (\n        <TabContent\n          // oxlint-disable-next-line react/no-array-index-key -- Nothing else to key on\n          key={idPrefix + part.name + i}\n          value={tabValue(part, i)}\n          className=\"pl-3 !pt-0\"\n        >\n          <Part part={part} />\n        </TabContent>\n      ))}\n    </Tabs>\n  );\n}\n\nfunction Part({ part }: { part: MultipartPart }) {\n  const mimeType = part.headers.contentType.mediaType ?? null;\n  const contentTypeHeader = part.headers.get(\"content-type\");\n\n  const { uint8Array, content, detectedLanguage } = useMemo(() => {\n    const uint8Array = new Uint8Array(part.arrayBuffer);\n    const content = new TextDecoder().decode(part.arrayBuffer);\n    const detectedLanguage = languageFromContentType(contentTypeHeader, content);\n    return { uint8Array, content, detectedLanguage };\n  }, [part, contentTypeHeader]);\n\n  if (mimeType?.match(/^image\\/svg/i)) {\n    return <SvgViewer text={content} className=\"pb-2\" />;\n  }\n\n  if (mimeType?.match(/^image/i)) {\n    return <ImageViewer data={part.arrayBuffer} className=\"pb-2\" />;\n  }\n\n  if (mimeType?.match(/^audio/i)) {\n    return <AudioViewer data={uint8Array} />;\n  }\n\n  if (mimeType?.match(/^video/i)) {\n    return <VideoViewer data={uint8Array} />;\n  }\n\n  if (mimeType?.match(/csv|tab-separated/i)) {\n    return <CsvViewer text={content} className=\"bg-primary h-10 w-10\" />;\n  }\n\n  if (mimeType?.match(/^text\\/html/i) || detectedLanguage === \"html\") {\n    return <WebPageViewer html={content} />;\n  }\n\n  if (mimeType?.match(/pdf/i)) {\n    return (\n      <Suspense fallback={<LoadingIcon />}>\n        <PdfViewer data={uint8Array} />\n      </Suspense>\n    );\n  }\n\n  return <TextViewer text={content} language={detectedLanguage} stateKey={null} />;\n}\n\nfunction tabValue(part: MultipartPart, i: number) {\n  return `${part.name ?? \"\"}::${i}`;\n}\n"
  },
  {
    "path": "src-web/components/responseViewers/PdfViewer.css",
    "content": ".react-pdf__Document * {\n  user-select: text;\n}\n"
  },
  {
    "path": "src-web/components/responseViewers/PdfViewer.tsx",
    "content": "import \"react-pdf/dist/Page/TextLayer.css\";\nimport \"react-pdf/dist/Page/AnnotationLayer.css\";\nimport { convertFileSrc } from \"@tauri-apps/api/core\";\nimport \"./PdfViewer.css\";\nimport type { PDFDocumentProxy } from \"pdfjs-dist\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { Document, Page } from \"react-pdf\";\nimport { useContainerSize } from \"../../hooks/useContainerQuery\";\nimport { fireAndForget } from \"../../lib/fireAndForget\";\n\nfireAndForget(\n  import(\"react-pdf\").then(({ pdfjs }) => {\n    pdfjs.GlobalWorkerOptions.workerSrc = new URL(\n      \"pdfjs-dist/build/pdf.worker.min.mjs\",\n      import.meta.url,\n    ).toString();\n  }),\n);\n\ninterface Props {\n  bodyPath?: string;\n  data?: Uint8Array;\n}\n\nconst options = {\n  cMapUrl: \"/cmaps/\",\n  standardFontDataUrl: \"/standard_fonts/\",\n};\n\nexport function PdfViewer({ bodyPath, data }: Props) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [numPages, setNumPages] = useState<number>();\n  const [src, setSrc] = useState<string | { data: Uint8Array }>();\n\n  const { width: containerWidth } = useContainerSize(containerRef);\n\n  useEffect(() => {\n    if (bodyPath) {\n      setSrc(convertFileSrc(bodyPath));\n    } else if (data) {\n      // Create a copy to avoid \"Buffer is already detached\" errors\n      // This happens when the ArrayBuffer is transferred/detached elsewhere\n      const dataCopy = new Uint8Array(data);\n      setSrc({ data: dataCopy });\n    } else {\n      setSrc(undefined);\n    }\n  }, [bodyPath, data]);\n\n  const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {\n    setNumPages(nextNumPages);\n  };\n  return (\n    <div ref={containerRef} className=\"w-full h-full overflow-y-auto\">\n      <Document\n        file={src}\n        options={options}\n        onLoadSuccess={onDocumentLoadSuccess}\n        externalLinkTarget=\"_blank\"\n        externalLinkRel=\"noopener noreferrer\"\n      >\n        {Array.from({ length: numPages ?? 0 }, (_, index) => (\n          <Page\n            className=\"mb-6 select-all\"\n            renderTextLayer\n            renderAnnotationLayer\n            key={`page_${index + 1}`}\n            pageNumber={index + 1}\n            width={containerWidth}\n          />\n        ))}\n      </Document>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/components/responseViewers/SvgViewer.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\ninterface Props {\n  text: string;\n  className?: string;\n}\n\nexport function SvgViewer({ text, className }: Props) {\n  const [src, setSrc] = useState<string | null>(null);\n\n  useEffect(() => {\n    if (!text) {\n      return setSrc(null);\n    }\n\n    const blob = new Blob([text], { type: \"image/svg+xml;charset=utf-8\" });\n    const url = URL.createObjectURL(blob);\n    setSrc(url);\n\n    return () => URL.revokeObjectURL(url);\n  }, [text]);\n\n  if (src == null) {\n    return null;\n  }\n\n  return (\n    <img src={src} alt=\"Response preview\" className={className ?? \"max-w-full max-h-full pb-2\"} />\n  );\n}\n"
  },
  {
    "path": "src-web/components/responseViewers/TextViewer.tsx",
    "content": "import classNames from \"classnames\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useMemo } from \"react\";\nimport { createGlobalState } from \"react-use\";\nimport { useDebouncedValue } from \"../../hooks/useDebouncedValue\";\nimport { useFormatText } from \"../../hooks/useFormatText\";\nimport type { EditorProps } from \"../core/Editor/Editor\";\nimport { hyperlink } from \"../core/Editor/hyperlink/extension\";\nimport { Editor } from \"../core/Editor/LazyEditor\";\nimport { IconButton } from \"../core/IconButton\";\nimport { Input } from \"../core/Input\";\n\nconst extraExtensions = [hyperlink];\n\ninterface Props {\n  text: string;\n  language: EditorProps[\"language\"];\n  stateKey: string | null;\n  pretty?: boolean;\n  className?: string;\n  onFilter?: (filter: string) => {\n    data: string | null | undefined;\n    isPending: boolean;\n    error: boolean;\n  };\n}\n\nconst useFilterText = createGlobalState<Record<string, string | null>>({});\n\nexport function TextViewer({ language, text, stateKey, pretty, className, onFilter }: Props) {\n  const [filterTextMap, setFilterTextMap] = useFilterText();\n  const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;\n  const debouncedFilterText = useDebouncedValue(filterText);\n  const setFilterText = useCallback(\n    (v: string | null) => {\n      if (!stateKey) return;\n      setFilterTextMap((m) => ({ ...m, [stateKey]: v }));\n    },\n    [setFilterTextMap, stateKey],\n  );\n\n  const isSearching = filterText != null;\n  const filteredResponse =\n    onFilter && debouncedFilterText\n      ? onFilter(debouncedFilterText)\n      : { data: null, isPending: false, error: false };\n\n  const toggleSearch = useCallback(() => {\n    if (isSearching) {\n      setFilterText(null);\n    } else {\n      setFilterText(\"\");\n    }\n  }, [isSearching, setFilterText]);\n\n  const canFilter = onFilter && (language === \"json\" || language === \"xml\" || language === \"html\");\n\n  const actions = useMemo<ReactNode[]>(() => {\n    const nodes: ReactNode[] = [];\n\n    if (!canFilter) return nodes;\n\n    if (isSearching) {\n      nodes.push(\n        <div key=\"input\" className=\"w-full !opacity-100\">\n          <Input\n            key={stateKey ?? \"filter\"}\n            validate={!filteredResponse.error}\n            hideLabel\n            autoFocus\n            containerClassName=\"bg-surface\"\n            size=\"sm\"\n            placeholder={language === \"json\" ? \"JSONPath expression\" : \"XPath expression\"}\n            label=\"Filter expression\"\n            name=\"filter\"\n            defaultValue={filterText}\n            onKeyDown={(e) => e.key === \"Escape\" && toggleSearch()}\n            onChange={setFilterText}\n            stateKey={stateKey ? `filter.${stateKey}` : null}\n          />\n        </div>,\n      );\n    }\n\n    nodes.push(\n      <IconButton\n        key=\"icon\"\n        size=\"sm\"\n        isLoading={filteredResponse.isPending}\n        icon={isSearching ? \"x\" : \"filter\"}\n        title={isSearching ? \"Close filter\" : \"Filter response\"}\n        onClick={toggleSearch}\n        className={classNames(\"border !border-border-subtle\", isSearching && \"!opacity-100\")}\n      />,\n    );\n\n    return nodes;\n  }, [\n    canFilter,\n    filterText,\n    filteredResponse.error,\n    filteredResponse.isPending,\n    isSearching,\n    language,\n    stateKey,\n    setFilterText,\n    toggleSearch,\n  ]);\n\n  const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });\n  if (formattedBody == null) {\n    return null;\n  }\n\n  let body: string;\n  if (isSearching && filterText?.length > 0) {\n    if (filteredResponse.error) {\n      body = \"\";\n    } else {\n      body = filteredResponse.data != null ? filteredResponse.data : \"\";\n    }\n  } else {\n    body = formattedBody;\n  }\n\n  // Decode unicode sequences in the text to readable characters\n  if (language === \"json\" && pretty) {\n    body = decodeUnicodeLiterals(body);\n    body = body.replace(/\\\\\\//g, \"/\"); // Hide unnecessary escaping of '/' by some older frameworks\n  }\n\n  return (\n    <Editor\n      readOnly\n      className={className}\n      defaultValue={body}\n      language={language}\n      actions={actions}\n      extraExtensions={extraExtensions}\n      stateKey={stateKey}\n    />\n  );\n}\n\n/** Convert \\uXXXX to actual Unicode characters */\nfunction decodeUnicodeLiterals(text: string): string {\n  return text.replace(/\\\\u([0-9a-fA-F]{4})/g, (_, hex) => {\n    const charCode = Number.parseInt(hex, 16);\n    return String.fromCharCode(charCode);\n  });\n}\n"
  },
  {
    "path": "src-web/components/responseViewers/VideoViewer.tsx",
    "content": "import { convertFileSrc } from \"@tauri-apps/api/core\";\nimport { useEffect, useState } from \"react\";\n\ninterface Props {\n  bodyPath?: string;\n  data?: Uint8Array;\n}\n\nexport function VideoViewer({ bodyPath, data }: Props) {\n  const [src, setSrc] = useState<string>();\n\n  useEffect(() => {\n    if (bodyPath) {\n      setSrc(convertFileSrc(bodyPath));\n    } else if (data) {\n      const blob = new Blob([new Uint8Array(data)], { type: \"video/mp4\" });\n      const url = URL.createObjectURL(blob);\n      setSrc(url);\n      return () => URL.revokeObjectURL(url);\n    } else {\n      setSrc(undefined);\n    }\n  }, [bodyPath, data]);\n\n  // oxlint-disable-next-line jsx-a11y/media-has-caption\n  return <video className=\"w-full\" controls src={src} />;\n}\n"
  },
  {
    "path": "src-web/components/responseViewers/WebPageViewer.tsx",
    "content": "import { useMemo } from \"react\";\n\ninterface Props {\n  html: string;\n  baseUrl?: string;\n}\n\nexport function WebPageViewer({ html, baseUrl }: Props) {\n  const contentForIframe: string | undefined = useMemo(() => {\n    if (baseUrl && html.includes(\"<head>\")) {\n      return html.replace(/<head>/gi, `<head><base href=\"${baseUrl}\"/>`);\n    }\n    return html;\n  }, [baseUrl, html]);\n\n  return (\n    <div className=\"h-full pb-3\">\n      <iframe\n        key={html ? \"has-body\" : \"no-body\"}\n        title=\"Yaak response preview\"\n        srcDoc={contentForIframe}\n        sandbox=\"allow-scripts allow-forms\"\n        referrerPolicy=\"no-referrer\"\n        className=\"h-full w-full rounded-lg border border-border-subtle\"\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/font-size.ts",
    "content": "// Listen for settings changes, the re-compute theme\nimport { listen } from \"@tauri-apps/api/event\";\nimport type { ModelPayload } from \"@yaakapp-internal/models\";\nimport { fireAndForget } from \"./lib/fireAndForget\";\nimport { getSettings } from \"./lib/settings\";\n\nfunction setFontSizeOnDocument(fontSize: number) {\n  document.documentElement.style.fontSize = `${fontSize}px`;\n}\n\nlisten<ModelPayload>(\"model_write\", async (event) => {\n  if (event.payload.change.type !== \"upsert\") return;\n  if (event.payload.model.model !== \"settings\") return;\n  setFontSizeOnDocument(event.payload.model.interfaceFontSize);\n}).catch(console.error);\n\nfireAndForget(getSettings().then((settings) => setFontSizeOnDocument(settings.interfaceFontSize)));\n"
  },
  {
    "path": "src-web/font.ts",
    "content": "// Listen for settings changes, the re-compute theme\nimport { listen } from \"@tauri-apps/api/event\";\nimport type { ModelPayload, Settings } from \"@yaakapp-internal/models\";\nimport { fireAndForget } from \"./lib/fireAndForget\";\nimport { getSettings } from \"./lib/settings\";\n\nfunction setFonts(settings: Settings) {\n  document.documentElement.style.setProperty(\"--font-family-editor\", settings.editorFont ?? \"\");\n  document.documentElement.style.setProperty(\n    \"--font-family-interface\",\n    settings.interfaceFont ?? \"\",\n  );\n}\n\nlisten<ModelPayload>(\"model_write\", async (event) => {\n  if (event.payload.change.type !== \"upsert\") return;\n  if (event.payload.model.model !== \"settings\") return;\n  setFonts(event.payload.model);\n}).catch(console.error);\n\nfireAndForget(getSettings().then((settings) => setFonts(settings)));\n"
  },
  {
    "path": "src-web/hooks/useActiveCookieJar.ts",
    "content": "import { useSearch } from \"@tanstack/react-router\";\nimport type { CookieJar } from \"@yaakapp-internal/models\";\nimport { cookieJarsAtom } from \"@yaakapp-internal/models\";\nimport { atom, useAtomValue } from \"jotai\";\nimport { useEffect } from \"react\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { setWorkspaceSearchParams } from \"../lib/setWorkspaceSearchParams\";\n\nexport const activeCookieJarAtom = atom<CookieJar | null>(null);\n\nexport function useActiveCookieJar() {\n  return useAtomValue(activeCookieJarAtom);\n}\n\nexport function useSubscribeActiveCookieJarId() {\n  const search = useSearch({ strict: false });\n  const cookieJarId = search.cookie_jar_id;\n  const cookieJars = useAtomValue(cookieJarsAtom);\n\n  useEffect(() => {\n    if (search == null) return; // Happens during Vite hot reload\n    const activeCookieJar = cookieJars?.find((j) => j.id === cookieJarId) ?? null;\n    jotaiStore.set(activeCookieJarAtom, activeCookieJar);\n  }, [cookieJarId, cookieJars, search]);\n}\n\nexport function getActiveCookieJar() {\n  return jotaiStore.get(activeCookieJarAtom);\n}\n\nexport function useEnsureActiveCookieJar() {\n  const cookieJars = useAtomValue(cookieJarsAtom);\n  const { cookie_jar_id: activeCookieJarId } = useSearch({ from: \"/workspaces/$workspaceId/\" });\n\n  // Set the active cookie jar to the first one, if none set\n  // NOTE: We only run this on cookieJars to prevent data races when switching workspaces since a lot of\n  //  things change when switching workspaces, and we don't currently have a good way to ensure that all\n  //  stores have updated.\n  // TODO: Create a global data store that can handle this case\n  // oxlint-disable-next-line react-hooks/exhaustive-deps\n  useEffect(() => {\n    if (cookieJars == null) return; // Hasn't loaded yet\n\n    if (cookieJars.find((j) => j.id === activeCookieJarId)) {\n      return; // There's an active jar\n    }\n\n    const firstJar = cookieJars[0];\n    if (firstJar == null) {\n      console.log(`Workspace doesn't have any cookie jars to activate`);\n      return;\n    }\n\n    // There's no active jar, so set it to the first one\n    console.log(\"Defaulting active cookie jar to first jar\", firstJar);\n    setWorkspaceSearchParams({ cookie_jar_id: firstJar.id });\n  }, [cookieJars]);\n}\n"
  },
  {
    "path": "src-web/hooks/useActiveEnvironment.ts",
    "content": "import { useSearch } from \"@tanstack/react-router\";\nimport type { Environment } from \"@yaakapp-internal/models\";\nimport { environmentsAtom } from \"@yaakapp-internal/models\";\nimport { atom, useAtomValue } from \"jotai\";\nimport { useEffect } from \"react\";\nimport { jotaiStore } from \"../lib/jotai\";\n\nexport const activeEnvironmentIdAtom = atom<string>();\n\nexport const activeEnvironmentAtom = atom<Environment | null>((get) => {\n  const activeEnvironmentId = get(activeEnvironmentIdAtom);\n  return get(environmentsAtom).find((e) => e.id === activeEnvironmentId) ?? null;\n});\n\nexport function useActiveEnvironment() {\n  return useAtomValue(activeEnvironmentAtom);\n}\n\nexport function getActiveEnvironment() {\n  return jotaiStore.get(activeEnvironmentAtom);\n}\n\nexport function useSubscribeActiveEnvironmentId() {\n  const { environment_id } = useSearch({ strict: false });\n  useEffect(\n    () => jotaiStore.set(activeEnvironmentIdAtom, environment_id ?? undefined),\n    [environment_id],\n  );\n}\n"
  },
  {
    "path": "src-web/hooks/useActiveEnvironmentVariables.ts",
    "content": "import { useAtomValue } from \"jotai\";\nimport { activeEnvironmentAtom } from \"./useActiveEnvironment\";\nimport { useEnvironmentVariables } from \"./useEnvironmentVariables\";\n\nexport function useActiveEnvironmentVariables() {\n  const activeEnvironment = useAtomValue(activeEnvironmentAtom);\n  return useEnvironmentVariables(activeEnvironment?.id ?? null).map((v) => v.variable);\n}\n"
  },
  {
    "path": "src-web/hooks/useActiveFolder.ts",
    "content": "import { foldersAtom } from \"@yaakapp-internal/models\";\nimport { atom } from \"jotai\";\nimport { activeFolderIdAtom } from \"./useActiveFolderId\";\n\nexport const activeFolderAtom = atom((get) => {\n  const activeFolderId = get(activeFolderIdAtom);\n  const folders = get(foldersAtom);\n  return folders.find((r) => r.id === activeFolderId) ?? null;\n});\n"
  },
  {
    "path": "src-web/hooks/useActiveFolderId.ts",
    "content": "import { useSearch } from \"@tanstack/react-router\";\nimport { atom } from \"jotai\";\nimport { useEffect } from \"react\";\nimport { jotaiStore } from \"../lib/jotai\";\n\nexport const activeFolderIdAtom = atom<string | null>(null);\n\nexport function useSubscribeActiveFolderId() {\n  const { folder_id } = useSearch({ strict: false });\n  useEffect(() => jotaiStore.set(activeFolderIdAtom, folder_id ?? null), [folder_id]);\n}\n"
  },
  {
    "path": "src-web/hooks/useActiveRequest.ts",
    "content": "import type { GrpcRequest, HttpRequest, WebsocketRequest } from \"@yaakapp-internal/models\";\nimport { atom, useAtomValue } from \"jotai\";\nimport { activeRequestIdAtom } from \"./useActiveRequestId\";\nimport { allRequestsAtom } from \"./useAllRequests\";\n\nexport const activeRequestAtom = atom((get) => {\n  const activeRequestId = get(activeRequestIdAtom);\n  const requests = get(allRequestsAtom);\n  return requests.find((r) => r.id === activeRequestId) ?? null;\n});\n\ninterface TypeMap {\n  http_request: HttpRequest;\n  grpc_request: GrpcRequest;\n  websocket_request: WebsocketRequest;\n}\n\nexport function useActiveRequest<T extends keyof TypeMap>(model?: T): TypeMap[T] | null {\n  const activeRequest = useAtomValue(activeRequestAtom);\n  if (model == null) return activeRequest as TypeMap[T];\n  if (activeRequest?.model === model) return activeRequest as TypeMap[T];\n  return null;\n}\n"
  },
  {
    "path": "src-web/hooks/useActiveRequestId.ts",
    "content": "import { useSearch } from \"@tanstack/react-router\";\nimport { atom } from \"jotai\";\nimport { useEffect } from \"react\";\nimport { jotaiStore } from \"../lib/jotai\";\n\nexport const activeRequestIdAtom = atom<string | null>(null);\n\nexport function useSubscribeActiveRequestId() {\n  const { request_id } = useSearch({ strict: false });\n  useEffect(() => jotaiStore.set(activeRequestIdAtom, request_id ?? null), [request_id]);\n}\n"
  },
  {
    "path": "src-web/hooks/useActiveWorkspace.ts",
    "content": "import { useParams } from \"@tanstack/react-router\";\nimport { workspaceMetasAtom, workspacesAtom } from \"@yaakapp-internal/models\";\nimport { atom } from \"jotai\";\nimport { useEffect } from \"react\";\nimport { jotaiStore } from \"../lib/jotai\";\n\nexport const activeWorkspaceIdAtom = atom<string | null>(null);\n\nexport const activeWorkspaceAtom = atom((get) => {\n  const activeWorkspaceId = get(activeWorkspaceIdAtom);\n  const workspaces = get(workspacesAtom);\n  return workspaces.find((w) => w.id === activeWorkspaceId) ?? null;\n});\n\nexport const activeWorkspaceMetaAtom = atom((get) => {\n  const activeWorkspaceId = get(activeWorkspaceIdAtom);\n  const workspaceMetas = get(workspaceMetasAtom);\n  return workspaceMetas.find((m) => m.workspaceId === activeWorkspaceId) ?? null;\n});\n\nexport function useSubscribeActiveWorkspaceId() {\n  const { workspaceId } = useParams({ strict: false });\n  useEffect(() => jotaiStore.set(activeWorkspaceIdAtom, workspaceId ?? null), [workspaceId]);\n}\n"
  },
  {
    "path": "src-web/hooks/useActiveWorkspaceChangedToast.tsx",
    "content": "import { useAtomValue } from \"jotai\";\nimport { useEffect, useState } from \"react\";\nimport { InlineCode } from \"../components/core/InlineCode\";\nimport { showToast } from \"../lib/toast\";\nimport { activeWorkspaceAtom } from \"./useActiveWorkspace\";\n\nexport function useActiveWorkspaceChangedToast() {\n  const activeWorkspace = useAtomValue(activeWorkspaceAtom);\n  const [id, setId] = useState<string | null>(activeWorkspace?.id ?? null);\n\n  useEffect(() => {\n    // Early return if same or invalid active workspace\n    if (id === activeWorkspace?.id || activeWorkspace == null) return;\n\n    setId(activeWorkspace?.id ?? null);\n\n    // Don't notify on the first load\n    if (id === null) return;\n\n    showToast({\n      id: `workspace-changed-${activeWorkspace.id}`,\n      timeout: 3000,\n      message: (\n        <>\n          Activated workspace{\" \"}\n          <InlineCode className=\"whitespace-nowrap\">{activeWorkspace.name}</InlineCode>\n        </>\n      ),\n    });\n  }, [activeWorkspace, id]);\n}\n"
  },
  {
    "path": "src-web/hooks/useAllRequests.ts",
    "content": "import {\n  grpcRequestsAtom,\n  httpRequestsAtom,\n  websocketRequestsAtom,\n} from \"@yaakapp-internal/models\";\nimport { atom, useAtomValue } from \"jotai\";\n\nexport const allRequestsAtom = atom((get) => [\n  ...get(httpRequestsAtom),\n  ...get(grpcRequestsAtom),\n  ...get(websocketRequestsAtom),\n]);\n\nexport function useAllRequests() {\n  return useAtomValue(allRequestsAtom);\n}\n"
  },
  {
    "path": "src-web/hooks/useAuthTab.tsx",
    "content": "import type { Folder } from \"@yaakapp-internal/models\";\nimport { modelTypeLabel, patchModel } from \"@yaakapp-internal/models\";\nimport { useMemo } from \"react\";\nimport { openFolderSettings } from \"../commands/openFolderSettings\";\nimport { openWorkspaceSettings } from \"../commands/openWorkspaceSettings\";\nimport { Icon } from \"../components/core/Icon\";\nimport { IconTooltip } from \"../components/core/IconTooltip\";\nimport { InlineCode } from \"../components/core/InlineCode\";\nimport { HStack } from \"../components/core/Stacks\";\nimport type { TabItem } from \"../components/core/Tabs/Tabs\";\nimport { capitalize } from \"../lib/capitalize\";\nimport { showConfirm } from \"../lib/confirm\";\nimport { resolvedModelName } from \"../lib/resolvedModelName\";\nimport { useHttpAuthenticationSummaries } from \"./useHttpAuthentication\";\nimport type { AuthenticatedModel } from \"./useInheritedAuthentication\";\nimport { useInheritedAuthentication } from \"./useInheritedAuthentication\";\nimport { useModelAncestors } from \"./useModelAncestors\";\n\nexport function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedModel | null) {\n  const authentication = useHttpAuthenticationSummaries();\n  const inheritedAuth = useInheritedAuthentication(model);\n  const ancestors = useModelAncestors(model);\n  const parentModel = ancestors[0] ?? null;\n\n  return useMemo<TabItem[]>(() => {\n    if (model == null) return [];\n\n    const tab: TabItem = {\n      value: tabValue,\n      label: \"Auth\",\n      options: {\n        value: model.authenticationType,\n        items: [\n          ...authentication.map((a) => ({\n            label: a.label || \"UNKNOWN\",\n            shortLabel: a.shortLabel,\n            value: a.name,\n          })),\n          { type: \"separator\" },\n          {\n            label: \"Inherit from Parent\",\n            shortLabel:\n              inheritedAuth != null && inheritedAuth.authenticationType !== \"none\" ? (\n                <HStack space={1.5}>\n                  {authentication.find((a) => a.name === inheritedAuth.authenticationType)\n                    ?.shortLabel ?? \"UNKNOWN\"}\n                  <IconTooltip\n                    icon=\"magic_wand\"\n                    iconSize=\"xs\"\n                    content=\"Authentication was inherited from an ancestor\"\n                  />\n                </HStack>\n              ) : (\n                \"Auth\"\n              ),\n            value: null,\n          },\n          { label: \"No Auth\", shortLabel: \"No Auth\", value: \"none\" },\n        ],\n        itemsAfter: (() => {\n          const actions: (\n            | { type: \"separator\"; label: string }\n            | { label: string; leftSlot: React.ReactNode; onSelect: () => Promise<void> }\n          )[] = [];\n\n          // Promote: move auth from current model up to parent\n          if (\n            parentModel &&\n            model.authenticationType &&\n            model.authenticationType !== \"none\" &&\n            (parentModel.authenticationType == null || parentModel.authenticationType === \"none\")\n          ) {\n            actions.push(\n              { type: \"separator\", label: \"Actions\" },\n              {\n                label: `Promote to ${capitalize(parentModel.model)}`,\n                leftSlot: (\n                  <Icon\n                    icon={parentModel.model === \"workspace\" ? \"corner_right_up\" : \"folder_up\"}\n                  />\n                ),\n                onSelect: async () => {\n                  const confirmed = await showConfirm({\n                    id: \"promote-auth-confirm\",\n                    title: \"Promote Authentication\",\n                    confirmText: \"Promote\",\n                    description: (\n                      <>\n                        Move authentication config to{\" \"}\n                        <InlineCode>{resolvedModelName(parentModel)}</InlineCode>?\n                      </>\n                    ),\n                  });\n                  if (confirmed) {\n                    await patchModel(model, { authentication: {}, authenticationType: null });\n                    await patchModel(parentModel, {\n                      authentication: model.authentication,\n                      authenticationType: model.authenticationType,\n                    });\n\n                    if (parentModel.model === \"folder\") {\n                      openFolderSettings(parentModel.id, \"auth\");\n                    } else {\n                      openWorkspaceSettings(\"auth\");\n                    }\n                  }\n                },\n              },\n            );\n          }\n\n          // Copy from ancestor: copy auth config down to current model\n          const ancestorWithAuth = ancestors.find(\n            (a) => a.authenticationType != null && a.authenticationType !== \"none\",\n          );\n          if (ancestorWithAuth) {\n            if (actions.length === 0) {\n              actions.push({ type: \"separator\", label: \"Actions\" });\n            }\n            actions.push({\n              label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,\n              leftSlot: (\n                <Icon\n                  icon={\n                    ancestorWithAuth.model === \"workspace\" ? \"corner_right_down\" : \"folder_down\"\n                  }\n                />\n              ),\n              onSelect: async () => {\n                const confirmed = await showConfirm({\n                  id: \"copy-auth-confirm\",\n                  title: \"Copy Authentication\",\n                  confirmText: \"Copy\",\n                  description: (\n                    <>\n                      Copy{\" \"}\n                      {authentication.find((a) => a.name === ancestorWithAuth.authenticationType)\n                        ?.label ?? \"authentication\"}{\" \"}\n                      config from <InlineCode>{resolvedModelName(ancestorWithAuth)}</InlineCode>?\n                      This will override the current authentication but will not affect the{\" \"}\n                      {modelTypeLabel(ancestorWithAuth).toLowerCase()}.\n                    </>\n                  ),\n                });\n                if (confirmed) {\n                  await patchModel(model, {\n                    authentication: { ...ancestorWithAuth.authentication },\n                    authenticationType: ancestorWithAuth.authenticationType,\n                  });\n                }\n              },\n            });\n          }\n\n          return actions.length > 0 ? actions : undefined;\n        })(),\n        onChange: async (authenticationType) => {\n          let authentication: Folder[\"authentication\"] = model.authentication;\n          if (model.authenticationType !== authenticationType) {\n            authentication = {\n              // Reset auth if changing types\n            };\n          }\n          await patchModel(model, { authentication, authenticationType });\n        },\n      },\n    };\n\n    return [tab];\n  }, [authentication, inheritedAuth, model, parentModel, tabValue, ancestors]);\n}\n"
  },
  {
    "path": "src-web/hooks/useCancelHttpResponse.ts",
    "content": "import { event } from \"@tauri-apps/api\";\nimport { useFastMutation } from \"./useFastMutation\";\n\nexport function useCancelHttpResponse(id: string | null) {\n  return useFastMutation<void>({\n    mutationKey: [\"cancel_http_response\", id],\n    mutationFn: () => event.emit(`cancel_http_response_${id}`),\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useCheckForUpdates.tsx",
    "content": "import { useMutation } from \"@tanstack/react-query\";\nimport { InlineCode } from \"../components/core/InlineCode\";\nimport { showAlert } from \"../lib/alert\";\nimport { appInfo } from \"../lib/appInfo\";\nimport { minPromiseMillis } from \"../lib/minPromiseMillis\";\nimport { invokeCmd } from \"../lib/tauri\";\n\nexport function useCheckForUpdates() {\n  return useMutation({\n    mutationKey: [\"check_for_updates\"],\n    mutationFn: async () => {\n      const hasUpdate: boolean = await minPromiseMillis(invokeCmd(\"cmd_check_for_updates\"), 500);\n      if (!hasUpdate) {\n        showAlert({\n          id: \"no-updates\",\n          title: \"No Update Available\",\n          body: (\n            <>\n              You are currently on the latest version <InlineCode>{appInfo.version}</InlineCode>\n            </>\n          ),\n        });\n      }\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useClickOutside.ts",
    "content": "import type { RefObject } from \"react\";\nimport { useEffect, useRef } from \"react\";\n\n/**\n * Get notified when a mouse click happens outside the target ref\n * @param ref The element to be notified when a mouse click happens outside it\n * @param onClickAway\n * @param ignored Optional outside element to ignore (useful for dropdown triggers)\n */\nexport function useClickOutside(\n  ref: RefObject<HTMLElement | null>,\n  onClickAway: (event: MouseEvent) => void,\n  ignored?: RefObject<HTMLElement | null>,\n) {\n  const savedCallback = useRef(onClickAway);\n\n  useEffect(() => {\n    savedCallback.current = onClickAway;\n  }, [onClickAway]);\n\n  useEffect(() => {\n    const handler = (event: MouseEvent) => {\n      if (ref.current == null || !(event.target instanceof HTMLElement)) {\n        return;\n      }\n      const isIgnored = ignored?.current?.contains(event.target);\n      const clickedOutside = !ref.current.contains(event.target);\n      if (!isIgnored && clickedOutside) {\n        savedCallback.current(event);\n      }\n    };\n    // NOTE: We're using mousedown instead of click to handle some edge cases like when a context\n    //  menu is open with the ctrl key.\n    document.addEventListener(\"mousedown\", handler, { capture: true });\n    document.addEventListener(\"contextmenu\", handler, { capture: true });\n    return () => {\n      document.removeEventListener(\"mousedown\", handler);\n      document.removeEventListener(\"contextmenu\", handler);\n    };\n  }, [ignored, ref]);\n}\n"
  },
  {
    "path": "src-web/hooks/useContainerQuery.ts",
    "content": "import type { RefObject } from \"react\";\nimport { useLayoutEffect, useState } from \"react\";\n\nexport function useContainerSize(ref: RefObject<HTMLElement | null>) {\n  const [size, setSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });\n\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (el) {\n      const observer = new ResizeObserver((entries) => {\n        for (const entry of entries) {\n          if (entry.target === el) {\n            setSize({ width: entry.contentRect.width, height: entry.contentRect.height });\n          }\n        }\n      });\n\n      observer.observe(el);\n\n      return () => {\n        observer.unobserve(el);\n        observer.disconnect();\n      };\n    }\n\n    return undefined;\n  }, [ref]);\n\n  return size;\n}\n"
  },
  {
    "path": "src-web/hooks/useCopyHttpResponse.ts",
    "content": "import type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { copyToClipboard } from \"../lib/copy\";\nimport { getResponseBodyText } from \"../lib/responseBody\";\nimport { useFastMutation } from \"./useFastMutation\";\n\nexport function useCopyHttpResponse(response: HttpResponse) {\n  return useFastMutation({\n    mutationKey: [\"copy_http_response\", response.id],\n    async mutationFn() {\n      const body = await getResponseBodyText({ response, filter: null });\n      copyToClipboard(body);\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useCreateCookieJar.ts",
    "content": "import { createWorkspaceModel } from \"@yaakapp-internal/models\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { showPrompt } from \"../lib/prompt\";\nimport { setWorkspaceSearchParams } from \"../lib/setWorkspaceSearchParams\";\nimport { activeWorkspaceIdAtom } from \"./useActiveWorkspace\";\nimport { useFastMutation } from \"./useFastMutation\";\n\nexport function useCreateCookieJar() {\n  return useFastMutation({\n    mutationKey: [\"create_cookie_jar\"],\n    mutationFn: async () => {\n      const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n      if (workspaceId == null) {\n        throw new Error(\"Cannot create cookie jar when there's no active workspace\");\n      }\n\n      const name = await showPrompt({\n        id: \"new-cookie-jar\",\n        title: \"New CookieJar\",\n        placeholder: \"My Jar\",\n        confirmText: \"Create\",\n        label: \"Name\",\n        defaultValue: \"My Jar\",\n      });\n      if (name == null) return null;\n\n      return createWorkspaceModel({ model: \"cookie_jar\", workspaceId, name });\n    },\n    onSuccess: async (cookieJarId) => {\n      setWorkspaceSearchParams({ cookie_jar_id: cookieJarId });\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useCreateDropdownItems.tsx",
    "content": "import type { HttpRequest, WebsocketRequest } from \"@yaakapp-internal/models\";\nimport type { GrpcRequest } from \"@yaakapp-internal/sync\";\nimport { useAtomValue } from \"jotai\";\nimport { useMemo } from \"react\";\nimport { createFolder } from \"../commands/commands\";\nimport type { DropdownItem } from \"../components/core/Dropdown\";\nimport { Icon } from \"../components/core/Icon\";\nimport { createRequestAndNavigate } from \"../lib/createRequestAndNavigate\";\nimport { generateId } from \"../lib/generateId\";\nimport { BODY_TYPE_GRAPHQL } from \"../lib/model_util\";\nimport { activeRequestAtom } from \"./useActiveRequest\";\nimport { activeWorkspaceIdAtom } from \"./useActiveWorkspace\";\n\nexport function useCreateDropdownItems({\n  hideFolder,\n  hideIcons,\n  folderId,\n}: {\n  hideFolder?: boolean;\n  hideIcons?: boolean;\n  folderId?: string | null;\n} = {}): DropdownItem[] {\n  const workspaceId = useAtomValue(activeWorkspaceIdAtom);\n  const activeRequest = useAtomValue(activeRequestAtom);\n\n  const items = useMemo((): DropdownItem[] => {\n    return getCreateDropdownItems({ hideFolder, hideIcons, folderId, activeRequest, workspaceId });\n  }, [activeRequest, folderId, hideFolder, hideIcons, workspaceId]);\n\n  return items;\n}\n\nexport function getCreateDropdownItems({\n  hideFolder,\n  hideIcons,\n  folderId: folderIdOption,\n  workspaceId,\n  activeRequest,\n  onCreate,\n}: {\n  hideFolder?: boolean;\n  hideIcons?: boolean;\n  folderId?: string | null;\n  workspaceId: string | null;\n  activeRequest: HttpRequest | GrpcRequest | WebsocketRequest | null;\n  onCreate?: (\n    model: \"http_request\" | \"grpc_request\" | \"websocket_request\" | \"folder\",\n    id: string,\n  ) => void;\n}): DropdownItem[] {\n  const folderId =\n    (folderIdOption === \"active-folder\" ? activeRequest?.folderId : folderIdOption) ?? null;\n\n  if (workspaceId == null) {\n    return [];\n  }\n\n  return [\n    {\n      label: \"HTTP\",\n      leftSlot: hideIcons ? undefined : <Icon icon=\"plus\" />,\n      onSelect: async () => {\n        const id = await createRequestAndNavigate({ model: \"http_request\", workspaceId, folderId });\n        onCreate?.(\"http_request\", id);\n      },\n    },\n    {\n      label: \"GraphQL\",\n      leftSlot: hideIcons ? undefined : <Icon icon=\"plus\" />,\n      onSelect: async () => {\n        const id = await createRequestAndNavigate({\n          model: \"http_request\",\n          workspaceId,\n          folderId,\n          bodyType: BODY_TYPE_GRAPHQL,\n          method: \"POST\",\n          headers: [{ name: \"Content-Type\", value: \"application/json\", id: generateId() }],\n        });\n        onCreate?.(\"http_request\", id);\n      },\n    },\n    {\n      label: \"gRPC\",\n      leftSlot: hideIcons ? undefined : <Icon icon=\"plus\" />,\n      onSelect: async () => {\n        const id = await createRequestAndNavigate({ model: \"grpc_request\", workspaceId, folderId });\n        onCreate?.(\"grpc_request\", id);\n      },\n    },\n    {\n      label: \"WebSocket\",\n      leftSlot: hideIcons ? undefined : <Icon icon=\"plus\" />,\n      onSelect: async () => {\n        const id = await createRequestAndNavigate({\n          model: \"websocket_request\",\n          workspaceId,\n          folderId,\n        });\n        onCreate?.(\"websocket_request\", id);\n      },\n    },\n    ...((hideFolder\n      ? []\n      : [\n          { type: \"separator\" },\n          {\n            label: \"Folder\",\n            leftSlot: hideIcons ? undefined : <Icon icon=\"plus\" />,\n            onSelect: async () => {\n              const id = await createFolder.mutateAsync({ folderId });\n              if (id != null) {\n                onCreate?.(\"folder\", id);\n              }\n            },\n          },\n        ]) as DropdownItem[]),\n  ];\n}\n"
  },
  {
    "path": "src-web/hooks/useCreateWorkspace.tsx",
    "content": "import { useCallback } from \"react\";\nimport { CreateWorkspaceDialog } from \"../components/CreateWorkspaceDialog\";\nimport { showDialog } from \"../lib/dialog\";\n\nexport function useCreateWorkspace() {\n  return useCallback(() => {\n    showDialog({\n      id: \"create-workspace\",\n      title: \"Create Workspace\",\n      size: \"sm\",\n      render: ({ hide }) => <CreateWorkspaceDialog hide={hide} />,\n    });\n  }, []);\n}\n"
  },
  {
    "path": "src-web/hooks/useDebouncedState.ts",
    "content": "import { debounce } from \"@yaakapp-internal/lib\";\nimport type { Dispatch, SetStateAction } from \"react\";\nimport { useMemo, useState } from \"react\";\n\nexport function useDebouncedState<T>(\n  defaultValue: T,\n  delay = 500,\n): [T, Dispatch<SetStateAction<T>>, Dispatch<SetStateAction<T>>] {\n  const [state, setState] = useState<T>(defaultValue);\n  const debouncedSetState = useMemo(() => debounce(setState, delay), [delay]);\n  return [state, debouncedSetState, setState];\n}\n"
  },
  {
    "path": "src-web/hooks/useDebouncedValue.ts",
    "content": "import { useEffect } from \"react\";\nimport { useDebouncedState } from \"./useDebouncedState\";\n\nexport function useDebouncedValue<T>(value: T, delay = 500) {\n  const [state, setState] = useDebouncedState<T>(value, delay);\n  useEffect(() => setState(value), [setState, value]);\n  return state;\n}\n"
  },
  {
    "path": "src-web/hooks/useDeleteGrpcConnections.ts",
    "content": "import { invokeCmd } from \"../lib/tauri\";\nimport { useFastMutation } from \"./useFastMutation\";\n\nexport function useDeleteGrpcConnections(requestId?: string) {\n  return useFastMutation({\n    mutationKey: [\"delete_grpc_connections\", requestId],\n    mutationFn: async () => {\n      if (requestId === undefined) return;\n      await invokeCmd(\"cmd_delete_all_grpc_connections\", { requestId });\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useDeleteHttpResponses.ts",
    "content": "import { invokeCmd } from \"../lib/tauri\";\nimport { useFastMutation } from \"./useFastMutation\";\n\nexport function useDeleteHttpResponses(requestId?: string) {\n  return useFastMutation({\n    mutationKey: [\"delete_http_responses\", requestId],\n    mutationFn: async () => {\n      if (requestId === undefined) return;\n      await invokeCmd(\"cmd_delete_all_http_responses\", { requestId });\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useDeleteSendHistory.tsx",
    "content": "import {\n  grpcConnectionsAtom,\n  httpResponsesAtom,\n  websocketConnectionsAtom,\n} from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { showAlert } from \"../lib/alert\";\nimport { showConfirmDelete } from \"../lib/confirm\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { pluralizeCount } from \"../lib/pluralize\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { activeWorkspaceIdAtom } from \"./useActiveWorkspace\";\nimport { useFastMutation } from \"./useFastMutation\";\n\nexport function useDeleteSendHistory() {\n  const httpResponses = useAtomValue(httpResponsesAtom);\n  const grpcConnections = useAtomValue(grpcConnectionsAtom);\n  const websocketConnections = useAtomValue(websocketConnectionsAtom);\n\n  const labels = [\n    httpResponses.length > 0 ? pluralizeCount(\"Http Response\", httpResponses.length) : null,\n    grpcConnections.length > 0 ? pluralizeCount(\"Grpc Connection\", grpcConnections.length) : null,\n    websocketConnections.length > 0\n      ? pluralizeCount(\"WebSocket Connection\", websocketConnections.length)\n      : null,\n  ].filter((l) => l != null);\n\n  return useFastMutation({\n    mutationKey: [\"delete_send_history\", labels],\n    mutationFn: async () => {\n      if (labels.length === 0) {\n        showAlert({\n          id: \"no-responses\",\n          title: \"Nothing to Delete\",\n          body: \"There is no Http, Grpc, or Websocket history\",\n        });\n        return;\n      }\n\n      const confirmed = await showConfirmDelete({\n        id: \"delete-send-history\",\n        title: \"Clear Send History\",\n        description: <>Delete {labels.join(\" and \")}?</>,\n      });\n      if (!confirmed) return false;\n\n      const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n      await invokeCmd(\"cmd_delete_send_history\", { workspaceId });\n      return true;\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useEnvironmentValueVisibility.ts",
    "content": "import type { Environment } from \"@yaakapp-internal/models\";\nimport { useKeyValue } from \"./useKeyValue\";\n\nexport function useEnvironmentValueVisibility(environment: Environment) {\n  return useKeyValue<boolean>({\n    namespace: \"global\",\n    key: [\"environmentValueVisibility\", environment.workspaceId],\n    fallback: false,\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useEnvironmentVariables.ts",
    "content": "import type { Environment, EnvironmentVariable } from \"@yaakapp-internal/models\";\nimport { foldersAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useMemo } from \"react\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { isBaseEnvironment, isFolderEnvironment } from \"../lib/model_util\";\nimport { useActiveEnvironment } from \"./useActiveEnvironment\";\nimport { useActiveRequest } from \"./useActiveRequest\";\nimport { useEnvironmentsBreakdown } from \"./useEnvironmentsBreakdown\";\nimport { useParentFolders } from \"./useParentFolders\";\n\nexport function useEnvironmentVariables(targetEnvironmentId: string | null) {\n  const { baseEnvironment, folderEnvironments, allEnvironments } = useEnvironmentsBreakdown();\n  const activeEnvironment = useActiveEnvironment();\n  const targetEnvironment = allEnvironments.find((e) => e.id === targetEnvironmentId) ?? null;\n  const activeRequest = useActiveRequest();\n  const folders = useAtomValue(foldersAtom);\n  const activeFolder = folders.find((f) => f.id === targetEnvironment?.parentId) ?? null;\n  const parentFolders = useParentFolders(activeFolder ?? activeRequest);\n\n  return useMemo(() => {\n    const varMap: Record<string, WrappedEnvironmentVariable> = {};\n    const folderVariables = parentFolders.flatMap((f) =>\n      wrapVariables(folderEnvironments.find((fe) => fe.parentId === f.id) ?? null),\n    );\n\n    // Add active environment variables to everything except sub environments\n    const activeEnvironmentVariables =\n      targetEnvironment == null || // Editing request\n      isFolderEnvironment(targetEnvironment) || // Editing folder variables\n      isBaseEnvironment(targetEnvironment) // Editing global variables\n        ? wrapVariables(activeEnvironment)\n        : wrapVariables(targetEnvironment); // Add own variables for sub environments\n\n    const allVariables = [\n      ...folderVariables,\n      ...activeEnvironmentVariables,\n      ...wrapVariables(baseEnvironment),\n    ];\n\n    for (const v of allVariables) {\n      if (!v.variable.enabled || !v.variable.name || v.variable.name in varMap) {\n        continue;\n      }\n      varMap[v.variable.name] = v;\n    }\n\n    return Object.values(varMap);\n  }, [activeEnvironment, baseEnvironment, folderEnvironments, parentFolders, targetEnvironment]);\n}\n\nexport interface WrappedEnvironmentVariable {\n  variable: EnvironmentVariable;\n  environment: Environment;\n  source: string;\n}\n\nfunction wrapVariables(e: Environment | null): WrappedEnvironmentVariable[] {\n  if (e == null) return [];\n  const folders = jotaiStore.get(foldersAtom);\n  return e.variables.map((v) => {\n    const folder = e.parentModel === \"folder\" ? folders.find((f) => f.id === e.parentId) : null;\n    const source = folder?.name ?? e.name;\n    return { variable: v, environment: e, source };\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useEnvironmentsBreakdown.ts",
    "content": "import { environmentsAtom } from \"@yaakapp-internal/models\";\nimport { atom, useAtomValue } from \"jotai\";\n\nexport const environmentsBreakdownAtom = atom((get) => {\n  const allEnvironments = get(environmentsAtom);\n  const baseEnvironments = allEnvironments.filter((e) => e.parentModel === \"workspace\") ?? [];\n\n  const subEnvironments =\n    allEnvironments\n      .filter((e) => e.parentModel === \"environment\")\n      ?.sort((a, b) => {\n        if (a.sortPriority === b.sortPriority) {\n          return a.updatedAt > b.updatedAt ? 1 : -1;\n        }\n        return a.sortPriority - b.sortPriority;\n      }) ?? [];\n\n  const folderEnvironments =\n    allEnvironments.filter((e) => e.parentModel === \"folder\" && e.parentId != null) ?? [];\n\n  const baseEnvironment = baseEnvironments[0] ?? null;\n  const otherBaseEnvironments = baseEnvironments.filter((e) => e.id !== baseEnvironment?.id) ?? [];\n  return {\n    allEnvironments,\n    baseEnvironment,\n    subEnvironments,\n    folderEnvironments,\n    otherBaseEnvironments,\n    baseEnvironments,\n  };\n});\n\nexport function useEnvironmentsBreakdown() {\n  return useAtomValue(environmentsBreakdownAtom);\n}\n"
  },
  {
    "path": "src-web/hooks/useEventViewerKeyboard.ts",
    "content": "import type { Virtualizer } from \"@tanstack/react-virtual\";\nimport { useCallback } from \"react\";\nimport { useKey } from \"react-use\";\n\ninterface UseEventViewerKeyboardProps {\n  totalCount: number;\n  activeIndex: number | null;\n  setActiveIndex: (index: number | null) => void;\n  virtualizer?: Virtualizer<HTMLDivElement, Element> | null;\n  isContainerFocused: () => boolean;\n  enabled?: boolean;\n  closePanel?: () => void;\n  openPanel?: () => void;\n}\n\nexport function useEventViewerKeyboard({\n  totalCount,\n  activeIndex,\n  setActiveIndex,\n  virtualizer,\n  isContainerFocused,\n  enabled = true,\n  closePanel,\n  openPanel,\n}: UseEventViewerKeyboardProps) {\n  const selectPrev = useCallback(() => {\n    if (totalCount === 0) return;\n\n    const newIndex = activeIndex == null ? 0 : Math.max(0, activeIndex - 1);\n    setActiveIndex(newIndex);\n    virtualizer?.scrollToIndex(newIndex, { align: \"auto\" });\n  }, [activeIndex, setActiveIndex, totalCount, virtualizer]);\n\n  const selectNext = useCallback(() => {\n    if (totalCount === 0) return;\n\n    const newIndex = activeIndex == null ? 0 : Math.min(totalCount - 1, activeIndex + 1);\n    setActiveIndex(newIndex);\n    virtualizer?.scrollToIndex(newIndex, { align: \"auto\" });\n  }, [activeIndex, setActiveIndex, totalCount, virtualizer]);\n\n  useKey(\n    (e) => e.key === \"ArrowUp\" || e.key === \"k\",\n    (e) => {\n      if (!enabled || !isContainerFocused()) return;\n      e.preventDefault();\n      selectPrev();\n    },\n    undefined,\n    [enabled, isContainerFocused, selectPrev],\n  );\n\n  useKey(\n    (e) => e.key === \"ArrowDown\" || e.key === \"j\",\n    (e) => {\n      if (!enabled || !isContainerFocused()) return;\n      e.preventDefault();\n      selectNext();\n    },\n    undefined,\n    [enabled, isContainerFocused, selectNext],\n  );\n\n  useKey(\n    (e) => e.key === \"Escape\",\n    (e) => {\n      if (!enabled || !isContainerFocused()) return;\n      e.preventDefault();\n      closePanel?.();\n    },\n    undefined,\n    [enabled, isContainerFocused, closePanel],\n  );\n\n  useKey(\n    (e) => e.key === \"Enter\" || e.key === \" \",\n    (e) => {\n      if (!enabled || !isContainerFocused() || activeIndex == null) return;\n      e.preventDefault();\n      openPanel?.();\n    },\n    undefined,\n    [enabled, isContainerFocused, activeIndex, openPanel],\n  );\n}\n"
  },
  {
    "path": "src-web/hooks/useExportData.tsx",
    "content": "import { workspacesAtom } from \"@yaakapp-internal/models\";\nimport { ExportDataDialog } from \"../components/ExportDataDialog\";\nimport { showAlert } from \"../lib/alert\";\nimport { showDialog } from \"../lib/dialog\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { showToast } from \"../lib/toast\";\nimport { activeWorkspaceAtom } from \"./useActiveWorkspace\";\nimport { useFastMutation } from \"./useFastMutation\";\n\nexport function useExportData() {\n  return useFastMutation({\n    mutationKey: [\"export_data\"],\n    onError: (err: string) => {\n      showAlert({ id: \"export-failed\", title: \"Export Failed\", body: err });\n    },\n    mutationFn: async () => {\n      const activeWorkspace = jotaiStore.get(activeWorkspaceAtom);\n      const workspaces = jotaiStore.get(workspacesAtom);\n\n      if (activeWorkspace == null || workspaces.length === 0) return;\n\n      showDialog({\n        id: \"export-data\",\n        title: \"Export Data\",\n        size: \"md\",\n        noPadding: true,\n        render: ({ hide }) => (\n          <ExportDataDialog\n            onHide={hide}\n            onSuccess={() => {\n              showToast({\n                color: \"success\",\n                message: \"Data export successful\",\n              });\n            }}\n          />\n        ),\n      });\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useFastMutation.ts",
    "content": "import type { MutationKey } from \"@tanstack/react-query\";\nimport { useMemo } from \"react\";\nimport { showToast } from \"../lib/toast\";\n\ninterface MutationOptions<TData, TError, TVariables> {\n  mutationKey: MutationKey;\n  mutationFn: (vars: TVariables) => Promise<TData>;\n  onSettled?: () => void;\n  onError?: (err: TError) => void;\n  onSuccess?: (data: TData) => void;\n  disableToastError?: boolean;\n}\n\ntype CallbackMutationOptions<TData, TError, TVariables> = Omit<\n  MutationOptions<TData, TError, TVariables>,\n  \"mutationKey\" | \"mutationFn\"\n>;\n\nexport function createFastMutation<TData = unknown, TError = unknown, TVariables = void>(\n  defaultArgs: MutationOptions<TData, TError, TVariables>,\n) {\n  const mutateAsync = async (\n    variables: TVariables,\n    args?: CallbackMutationOptions<TData, TError, TVariables>,\n  ) => {\n    const { mutationKey, mutationFn, disableToastError } = {\n      ...defaultArgs,\n      ...args,\n    };\n    try {\n      const data = await mutationFn(variables);\n      // Run both default and custom onSuccess callbacks\n      defaultArgs.onSuccess?.(data);\n      args?.onSuccess?.(data);\n      defaultArgs.onSettled?.();\n      args?.onSettled?.();\n      return data;\n    } catch (err: unknown) {\n      const stringKey = mutationKey.join(\".\");\n      const e = err as TError;\n      console.log(\"mutation error\", stringKey, e);\n      if (!disableToastError) {\n        showToast({\n          id: stringKey,\n          message: err instanceof Error ? err.message : String(err),\n          color: \"danger\",\n          timeout: 5000,\n        });\n      }\n      // Run both default and custom onError callbacks\n      defaultArgs.onError?.(e);\n      args?.onError?.(e);\n      defaultArgs.onSettled?.();\n      args?.onSettled?.();\n      throw e;\n    }\n  };\n\n  const mutate = (\n    variables: TVariables,\n    args?: CallbackMutationOptions<TData, TError, TVariables>,\n  ) => {\n    setTimeout(() => mutateAsync(variables, args));\n  };\n\n  return { mutateAsync, mutate };\n}\n\nexport function useFastMutation<TData = unknown, TError = unknown, TVariables = void>(\n  defaultArgs: MutationOptions<TData, TError, TVariables>,\n) {\n  return useMemo(() => {\n    return createFastMutation(defaultArgs);\n    // oxlint-disable-next-line react-hooks/exhaustive-deps -- Force it!\n  }, defaultArgs.mutationKey);\n}\n"
  },
  {
    "path": "src-web/hooks/useFloatingSidebarHidden.ts",
    "content": "import { useAtomValue } from \"jotai\";\nimport { activeWorkspaceAtom } from \"./useActiveWorkspace\";\nimport { useKeyValue } from \"./useKeyValue\";\n\nexport function useFloatingSidebarHidden() {\n  const activeWorkspace = useAtomValue(activeWorkspaceAtom);\n  const { set, value } = useKeyValue<boolean>({\n    namespace: \"no_sync\",\n    key: [\"floating_sidebar_hidden\", activeWorkspace?.id ?? \"n/a\"],\n    fallback: false,\n  });\n\n  return [value, set] as const;\n}\n"
  },
  {
    "path": "src-web/hooks/useFolderActions.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type { Folder } from \"@yaakapp-internal/models\";\nimport type {\n  CallFolderActionRequest,\n  FolderAction,\n  GetFolderActionsResponse,\n} from \"@yaakapp-internal/plugins\";\nimport { useMemo } from \"react\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { usePluginsKey } from \"./usePlugins\";\n\nexport type CallableFolderAction = Pick<FolderAction, \"label\" | \"icon\"> & {\n  call: (folder: Folder) => Promise<void>;\n};\n\nexport function useFolderActions() {\n  const pluginsKey = usePluginsKey();\n\n  const actionsResult = useQuery<CallableFolderAction[]>({\n    queryKey: [\"folder_actions\", pluginsKey],\n    queryFn: () => getFolderActions(),\n  });\n\n  // oxlint-disable-next-line react-hooks/exhaustive-deps\n  const actions = useMemo(() => {\n    return actionsResult.data ?? [];\n  }, [JSON.stringify(actionsResult.data)]);\n\n  return actions;\n}\n\nexport async function getFolderActions() {\n  const responses = await invokeCmd<GetFolderActionsResponse[]>(\"cmd_folder_actions\");\n  const actions = responses.flatMap((r) =>\n    r.actions.map((a, i) => ({\n      label: a.label,\n      icon: a.icon,\n      call: async (folder: Folder) => {\n        const payload: CallFolderActionRequest = {\n          index: i,\n          pluginRefId: r.pluginRefId,\n          args: { folder },\n        };\n        await invokeCmd(\"cmd_call_folder_action\", { req: payload });\n      },\n    })),\n  );\n\n  return actions;\n}\n"
  },
  {
    "path": "src-web/hooks/useFormatText.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type { EditorProps } from \"../components/core/Editor/Editor\";\nimport { tryFormatJson, tryFormatXml } from \"../lib/formatters\";\n\nexport function useFormatText({\n  text,\n  language,\n  pretty,\n}: {\n  text: string;\n  language: EditorProps[\"language\"];\n  pretty: boolean;\n}) {\n  return useQuery({\n    placeholderData: (prev) => prev, // Keep previous data on refetch\n    queryKey: [text, language, pretty],\n    queryFn: async () => {\n      if (text === \"\" || !pretty) {\n        return text;\n      }\n      if (language === \"json\") {\n        return tryFormatJson(text);\n      }\n      if (language === \"xml\" || language === \"html\") {\n        return tryFormatXml(text);\n      }\n      return text;\n    },\n  }).data;\n}\n"
  },
  {
    "path": "src-web/hooks/useGrpc.ts",
    "content": "import { useMutation, useQuery } from \"@tanstack/react-query\";\nimport { emit } from \"@tauri-apps/api/event\";\nimport type { GrpcConnection, GrpcRequest } from \"@yaakapp-internal/models\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { minPromiseMillis } from \"../lib/minPromiseMillis\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { activeEnvironmentIdAtom, useActiveEnvironment } from \"./useActiveEnvironment\";\nimport { useDebouncedValue } from \"./useDebouncedValue\";\n\nexport interface ReflectResponseService {\n  name: string;\n  methods: { name: string; schema: string; serverStreaming: boolean; clientStreaming: boolean }[];\n}\n\nexport function useGrpc(\n  req: GrpcRequest | null,\n  conn: GrpcConnection | null,\n  protoFiles: string[],\n) {\n  const requestId = req?.id ?? \"n/a\";\n  const environment = useActiveEnvironment();\n\n  const go = useMutation<void, string>({\n    mutationKey: [\"grpc_go\", conn?.id],\n    mutationFn: () =>\n      invokeCmd<void>(\"cmd_grpc_go\", { requestId, environmentId: environment?.id, protoFiles }),\n  });\n\n  const send = useMutation({\n    mutationKey: [\"grpc_send\", conn?.id],\n    mutationFn: ({ message }: { message: string }) =>\n      emit(`grpc_client_msg_${conn?.id ?? \"none\"}`, { Message: message }),\n  });\n\n  const cancel = useMutation({\n    mutationKey: [\"grpc_cancel\", conn?.id ?? \"n/a\"],\n    mutationFn: () => emit(`grpc_client_msg_${conn?.id ?? \"none\"}`, \"Cancel\"),\n  });\n\n  const commit = useMutation({\n    mutationKey: [\"grpc_commit\", conn?.id ?? \"n/a\"],\n    mutationFn: () => emit(`grpc_client_msg_${conn?.id ?? \"none\"}`, \"Commit\"),\n  });\n\n  const debouncedUrl = useDebouncedValue<string>(req?.url ?? \"\", 1000);\n\n  const reflect = useQuery<ReflectResponseService[], string>({\n    enabled: req != null,\n    queryKey: [\"grpc_reflect\", req?.id ?? \"n/a\", debouncedUrl, protoFiles],\n    staleTime: Infinity,\n    refetchOnMount: false,\n    refetchOnWindowFocus: false,\n    refetchOnReconnect: false,\n    queryFn: () => {\n      const environmentId = jotaiStore.get(activeEnvironmentIdAtom);\n      return minPromiseMillis<ReflectResponseService[]>(\n        invokeCmd(\"cmd_grpc_reflect\", { requestId, protoFiles, environmentId }),\n        300,\n      );\n    },\n  });\n\n  return {\n    go,\n    reflect,\n    cancel,\n    commit,\n    isStreaming: conn != null && conn.state !== \"closed\",\n    send,\n  };\n}\n"
  },
  {
    "path": "src-web/hooks/useGrpcProtoFiles.ts",
    "content": "import { getKeyValue } from \"../lib/keyValueStore\";\nimport { useKeyValue } from \"./useKeyValue\";\n\nexport function protoFilesArgs(requestId: string | null) {\n  return {\n    namespace: \"global\" as const,\n    key: [\"proto_files\", requestId ?? \"n/a\"],\n  };\n}\n\nexport function useGrpcProtoFiles(activeRequestId: string | null) {\n  return useKeyValue<string[]>({ ...protoFilesArgs(activeRequestId), fallback: [] });\n}\n\nexport async function getGrpcProtoFiles(activeRequestId: string | null) {\n  return getKeyValue<string[]>({ ...protoFilesArgs(activeRequestId), fallback: [] });\n}\n"
  },
  {
    "path": "src-web/hooks/useGrpcRequestActions.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type { GrpcRequest } from \"@yaakapp-internal/models\";\nimport type {\n  CallGrpcRequestActionRequest,\n  GetGrpcRequestActionsResponse,\n  GrpcRequestAction,\n} from \"@yaakapp-internal/plugins\";\nimport { useMemo } from \"react\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { getGrpcProtoFiles } from \"./useGrpcProtoFiles\";\nimport { usePluginsKey } from \"./usePlugins\";\n\nexport type CallableGrpcRequestAction = Pick<GrpcRequestAction, \"label\" | \"icon\"> & {\n  call: (grpcRequest: GrpcRequest) => Promise<void>;\n};\n\nexport function useGrpcRequestActions() {\n  const pluginsKey = usePluginsKey();\n\n  const actionsResult = useQuery<CallableGrpcRequestAction[]>({\n    queryKey: [\"grpc_request_actions\", pluginsKey],\n    queryFn: async () => {\n      return getGrpcRequestActions();\n    },\n  });\n\n  // oxlint-disable-next-line react-hooks/exhaustive-deps\n  const actions = useMemo(() => {\n    return actionsResult.data ?? [];\n  }, [JSON.stringify(actionsResult.data)]);\n\n  return actions;\n}\n\nexport async function getGrpcRequestActions() {\n  const responses = await invokeCmd<GetGrpcRequestActionsResponse[]>(\"cmd_grpc_request_actions\");\n\n  return responses.flatMap((r) =>\n    r.actions.map((a, i) => ({\n      label: a.label,\n      icon: a.icon,\n      call: async (grpcRequest: GrpcRequest) => {\n        const protoFiles = await getGrpcProtoFiles(grpcRequest.id);\n        const payload: CallGrpcRequestActionRequest = {\n          index: i,\n          pluginRefId: r.pluginRefId,\n          args: { grpcRequest, protoFiles },\n        };\n        await invokeCmd(\"cmd_call_grpc_request_action\", { req: payload });\n      },\n    })),\n  );\n}\n"
  },
  {
    "path": "src-web/hooks/useHeadersTab.tsx",
    "content": "import { useMemo } from \"react\";\nimport { CountBadge } from \"../components/core/CountBadge\";\nimport type { TabItem } from \"../components/core/Tabs/Tabs\";\nimport type { HeaderModel } from \"./useInheritedHeaders\";\nimport { useInheritedHeaders } from \"./useInheritedHeaders\";\n\nexport function useHeadersTab<T extends string>(\n  tabValue: T,\n  model: HeaderModel | null,\n  label?: string,\n) {\n  const inheritedHeaders = useInheritedHeaders(model);\n\n  return useMemo<TabItem[]>(() => {\n    if (model == null) return [];\n\n    const allHeaders = [\n      ...inheritedHeaders,\n      ...(model.model === \"grpc_request\" ? model.metadata : model.headers),\n    ];\n    const numHeaders = allHeaders.filter((h) => h.name).length;\n\n    const tab: TabItem = {\n      value: tabValue,\n      label: label ?? \"Headers\",\n      rightSlot: <CountBadge count={numHeaders} />,\n    };\n\n    return [tab];\n  }, [inheritedHeaders, label, model, tabValue]);\n}\n"
  },
  {
    "path": "src-web/hooks/useHotKey.ts",
    "content": "import { type } from \"@tauri-apps/plugin-os\";\nimport { debounce } from \"@yaakapp-internal/lib\";\nimport { settingsAtom } from \"@yaakapp-internal/models\";\nimport { atom, useAtomValue } from \"jotai\";\nimport { useEffect } from \"react\";\nimport { capitalize } from \"../lib/capitalize\";\nimport { jotaiStore } from \"../lib/jotai\";\n\nconst HOLD_KEYS = [\"Shift\", \"Control\", \"Command\", \"Alt\", \"Meta\"];\nconst SINGLE_WHITELIST = [\"Delete\", \"Enter\", \"Backspace\"];\n\nexport type HotkeyAction =\n  | \"app.zoom_in\"\n  | \"app.zoom_out\"\n  | \"app.zoom_reset\"\n  | \"command_palette.toggle\"\n  | \"editor.autocomplete\"\n  | \"environment_editor.toggle\"\n  | \"hotkeys.showHelp\"\n  | \"model.create\"\n  | \"model.duplicate\"\n  | \"request.send\"\n  | \"request.rename\"\n  | \"switcher.next\"\n  | \"switcher.prev\"\n  | \"switcher.toggle\"\n  | \"settings.show\"\n  | \"sidebar.filter\"\n  | \"sidebar.selected.delete\"\n  | \"sidebar.selected.duplicate\"\n  | \"sidebar.selected.move\"\n  | \"sidebar.selected.rename\"\n  | \"sidebar.expand_all\"\n  | \"sidebar.collapse_all\"\n  | \"sidebar.focus\"\n  | \"sidebar.context_menu\"\n  | \"url_bar.focus\"\n  | \"workspace_settings.show\";\n\n/** Default hotkeys for macOS (uses Meta for Cmd) */\nconst defaultHotkeysMac: Record<HotkeyAction, string[]> = {\n  \"app.zoom_in\": [\"Meta+Equal\"],\n  \"app.zoom_out\": [\"Meta+Minus\"],\n  \"app.zoom_reset\": [\"Meta+0\"],\n  \"command_palette.toggle\": [\"Meta+k\"],\n  \"editor.autocomplete\": [\"Control+Space\"],\n  \"environment_editor.toggle\": [\"Meta+Shift+e\"],\n  \"request.rename\": [\"Control+Shift+r\"],\n  \"request.send\": [\"Meta+Enter\", \"Meta+r\"],\n  \"hotkeys.showHelp\": [\"Meta+Shift+/\"],\n  \"model.create\": [\"Meta+n\"],\n  \"model.duplicate\": [\"Meta+d\"],\n  \"switcher.next\": [\"Control+Shift+Tab\"],\n  \"switcher.prev\": [\"Control+Tab\"],\n  \"switcher.toggle\": [\"Meta+p\"],\n  \"settings.show\": [\"Meta+,\"],\n  \"sidebar.filter\": [\"Meta+f\"],\n  \"sidebar.expand_all\": [\"Meta+Shift+Equal\"],\n  \"sidebar.collapse_all\": [\"Meta+Shift+Minus\"],\n  \"sidebar.selected.delete\": [\"Delete\", \"Meta+Backspace\"],\n  \"sidebar.selected.duplicate\": [\"Meta+d\"],\n  \"sidebar.selected.move\": [],\n  \"sidebar.selected.rename\": [\"Enter\"],\n  \"sidebar.focus\": [\"Meta+b\"],\n  \"sidebar.context_menu\": [\"Control+Enter\"],\n  \"url_bar.focus\": [\"Meta+l\"],\n  \"workspace_settings.show\": [\"Meta+;\"],\n};\n\n/** Default hotkeys for Windows/Linux (uses Control for Ctrl) */\nconst defaultHotkeysOther: Record<HotkeyAction, string[]> = {\n  \"app.zoom_in\": [\"Control+Equal\"],\n  \"app.zoom_out\": [\"Control+Minus\"],\n  \"app.zoom_reset\": [\"Control+0\"],\n  \"command_palette.toggle\": [\"Control+k\"],\n  \"editor.autocomplete\": [\"Control+Space\"],\n  \"environment_editor.toggle\": [\"Control+Shift+e\"],\n  \"request.rename\": [\"F2\"],\n  \"request.send\": [\"Control+Enter\", \"Control+r\"],\n  \"hotkeys.showHelp\": [\"Control+Shift+/\"],\n  \"model.create\": [\"Control+n\"],\n  \"model.duplicate\": [\"Control+d\"],\n  \"switcher.next\": [\"Control+Shift+Tab\"],\n  \"switcher.prev\": [\"Control+Tab\"],\n  \"switcher.toggle\": [\"Control+p\"],\n  \"settings.show\": [\"Control+,\"],\n  \"sidebar.filter\": [\"Control+f\"],\n  \"sidebar.expand_all\": [\"Control+Shift+Equal\"],\n  \"sidebar.collapse_all\": [\"Control+Shift+Minus\"],\n  \"sidebar.selected.delete\": [\"Delete\", \"Control+Backspace\"],\n  \"sidebar.selected.duplicate\": [\"Control+d\"],\n  \"sidebar.selected.move\": [],\n  \"sidebar.selected.rename\": [\"Enter\"],\n  \"sidebar.focus\": [\"Control+b\"],\n  \"sidebar.context_menu\": [\"Alt+Insert\"],\n  \"url_bar.focus\": [\"Control+l\"],\n  \"workspace_settings.show\": [\"Control+;\"],\n};\n\n/** Get the default hotkeys for the current platform */\nexport const defaultHotkeys: Record<HotkeyAction, string[]> =\n  type() === \"macos\" ? defaultHotkeysMac : defaultHotkeysOther;\n\n/** Atom that provides the effective hotkeys by merging defaults with user settings */\nexport const hotkeysAtom = atom((get) => {\n  const settings = get(settingsAtom);\n  const customHotkeys = settings?.hotkeys ?? {};\n\n  // Merge default hotkeys with custom hotkeys from settings\n  // Custom hotkeys override defaults for the same action\n  // An empty array means the hotkey is intentionally disabled\n  const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };\n  for (const [action, keys] of Object.entries(customHotkeys)) {\n    if (action in defaultHotkeys && Array.isArray(keys)) {\n      merged[action as HotkeyAction] = keys;\n    }\n  }\n  return merged;\n});\n\n/** Helper function to get current hotkeys from the store */\nfunction getHotkeys(): Record<HotkeyAction, string[]> {\n  return jotaiStore.get(hotkeysAtom);\n}\n\nconst hotkeyLabels: Record<HotkeyAction, string> = {\n  \"app.zoom_in\": \"Zoom In\",\n  \"app.zoom_out\": \"Zoom Out\",\n  \"app.zoom_reset\": \"Zoom to Actual Size\",\n  \"command_palette.toggle\": \"Toggle Command Palette\",\n  \"editor.autocomplete\": \"Trigger Autocomplete\",\n  \"environment_editor.toggle\": \"Edit Environments\",\n  \"hotkeys.showHelp\": \"Show Keyboard Shortcuts\",\n  \"model.create\": \"New Request\",\n  \"model.duplicate\": \"Duplicate Request\",\n  \"request.rename\": \"Rename Active Request\",\n  \"request.send\": \"Send Active Request\",\n  \"switcher.next\": \"Go To Previous Request\",\n  \"switcher.prev\": \"Go To Next Request\",\n  \"switcher.toggle\": \"Toggle Request Switcher\",\n  \"settings.show\": \"Open Settings\",\n  \"sidebar.filter\": \"Filter Sidebar\",\n  \"sidebar.expand_all\": \"Expand All Folders\",\n  \"sidebar.collapse_all\": \"Collapse All Folders\",\n  \"sidebar.selected.delete\": \"Delete Selected Sidebar Item\",\n  \"sidebar.selected.duplicate\": \"Duplicate Selected Sidebar Item\",\n  \"sidebar.selected.move\": \"Move Selected to Workspace\",\n  \"sidebar.selected.rename\": \"Rename Selected Sidebar Item\",\n  \"sidebar.focus\": \"Focus or Toggle Sidebar\",\n  \"sidebar.context_menu\": \"Show Context Menu\",\n  \"url_bar.focus\": \"Focus URL\",\n  \"workspace_settings.show\": \"Open Workspace Settings\",\n};\n\nconst layoutInsensitiveKeys = [\n  \"Equal\",\n  \"Minus\",\n  \"BracketLeft\",\n  \"BracketRight\",\n  \"Backquote\",\n  \"Space\",\n];\n\nexport const hotkeyActions: HotkeyAction[] = (\n  Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]\n).sort((a, b) => {\n  const scopeA = a.split(\".\")[0] || \"\";\n  const scopeB = b.split(\".\")[0] || \"\";\n  if (scopeA !== scopeB) {\n    return scopeA.localeCompare(scopeB);\n  }\n  return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);\n});\n\nexport type HotKeyOptions = {\n  enable?: boolean | (() => boolean);\n  priority?: number;\n  allowDefault?: boolean;\n};\n\ninterface Callback {\n  action: HotkeyAction;\n  callback: (e: KeyboardEvent) => void;\n  options: HotKeyOptions;\n}\n\nconst callbacksAtom = atom<Callback[]>([]);\nconst currentKeysAtom = atom<Set<string>>(new Set([]));\nexport const sortedCallbacksAtom = atom((get) =>\n  [...get(callbacksAtom)].sort((a, b) => (b.options.priority ?? 0) - (a.options.priority ?? 0)),\n);\n\nconst clearCurrentKeysDebounced = debounce(() => {\n  jotaiStore.set(currentKeysAtom, new Set([]));\n}, 5000);\n\nexport function useHotKey(\n  action: HotkeyAction | null,\n  callback: (e: KeyboardEvent) => void,\n  options: HotKeyOptions = {},\n) {\n  useEffect(() => {\n    if (action == null) return;\n    jotaiStore.set(callbacksAtom, (prev) => {\n      const without = prev.filter((cb) => {\n        const isTheSame = cb.action === action && cb.options.priority === options.priority;\n        return !isTheSame;\n      });\n      const newCb: Callback = { action, callback, options };\n      return [...without, newCb];\n    });\n    return () => {\n      jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback));\n    };\n  }, [action, callback, options]);\n}\n\nexport function useSubscribeHotKeys() {\n  useEffect(() => {\n    document.addEventListener(\"keyup\", handleKeyUp, { capture: true });\n    document.addEventListener(\"keydown\", handleKeyDown, { capture: true });\n    return () => {\n      document.removeEventListener(\"keydown\", handleKeyDown, { capture: true });\n      document.removeEventListener(\"keyup\", handleKeyUp, { capture: true });\n    };\n  }, []);\n}\n\nfunction handleKeyUp(e: KeyboardEvent) {\n  const keyToRemove = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;\n  const currentKeys = new Set(jotaiStore.get(currentKeysAtom));\n  currentKeys.delete(keyToRemove);\n\n  // Clear all keys if no longer holding modifier\n  // HACK: This is to get around the case of DOWN SHIFT -> DOWN : -> UP SHIFT -> UP ;\n  //  As you see, the \":\" is not removed because it turned into \";\" when shift was released\n  const isHoldingModifier = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;\n  if (!isHoldingModifier) {\n    currentKeys.clear();\n  }\n\n  jotaiStore.set(currentKeysAtom, currentKeys);\n}\n\nfunction handleKeyDown(e: KeyboardEvent) {\n  // Don't add key if not holding modifier\n  const isValidKeymapKey =\n    e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || SINGLE_WHITELIST.includes(e.key);\n  if (!isValidKeymapKey) {\n    return;\n  }\n\n  // Don't add hold keys\n  if (HOLD_KEYS.includes(e.key)) {\n    return;\n  }\n\n  const keyToAdd = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;\n  const currentKeys = new Set(jotaiStore.get(currentKeysAtom));\n  currentKeys.add(keyToAdd);\n\n  const currentKeysWithModifiers = new Set(currentKeys);\n  if (e.altKey) currentKeysWithModifiers.add(\"Alt\");\n  if (e.ctrlKey) currentKeysWithModifiers.add(\"Control\");\n  if (e.metaKey) currentKeysWithModifiers.add(\"Meta\");\n  if (e.shiftKey) currentKeysWithModifiers.add(\"Shift\");\n\n  // Don't trigger if the user is focused within an element that explicitly disableds hotkeys\n  if (document.activeElement?.closest(\"[data-disable-hotkey]\")) {\n    return;\n  }\n\n  // Don't support certain single-key combinations within inputs\n  if (\n    (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) &&\n    currentKeysWithModifiers.size === 1 &&\n    (currentKeysWithModifiers.has(\"Backspace\") || currentKeysWithModifiers.has(\"Delete\"))\n  ) {\n    return;\n  }\n\n  const executed: string[] = [];\n  for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) {\n    const enable = typeof options.enable === \"function\" ? options.enable() : options.enable;\n    if (enable === false) {\n      continue;\n    }\n\n    if (keysMatchAction(Array.from(currentKeysWithModifiers), action)) {\n      if (!options.allowDefault) {\n        e.preventDefault();\n        e.stopPropagation();\n      }\n      callback(e);\n      executed.push(`${action} ${options.priority ?? 0}`);\n      break;\n    }\n  }\n\n  if (executed.length > 0) {\n    console.log(\"Executed hotkey\", executed.join(\", \"));\n    jotaiStore.set(currentKeysAtom, new Set([]));\n  }\n  clearCurrentKeysDebounced();\n}\n\nexport function useHotkeyLabel(action: HotkeyAction): string {\n  return hotkeyLabels[action];\n}\n\nexport function getHotkeyScope(action: HotkeyAction): string {\n  const scope = action.split(\".\")[0];\n  return scope || \"\";\n}\n\nexport function formatHotkeyString(trigger: string): string[] {\n  const os = type();\n  const parts = trigger.split(\"+\");\n  const labelParts: string[] = [];\n\n  for (const p of parts) {\n    if (os === \"macos\") {\n      if (p === \"Meta\") {\n        labelParts.push(\"⌘\");\n      } else if (p === \"Shift\") {\n        labelParts.push(\"⇧\");\n      } else if (p === \"Control\") {\n        labelParts.push(\"⌃\");\n      } else if (p === \"Alt\") {\n        labelParts.push(\"⌥\");\n      } else if (p === \"Enter\") {\n        labelParts.push(\"↩\");\n      } else if (p === \"Tab\") {\n        labelParts.push(\"⇥\");\n      } else if (p === \"Backspace\") {\n        labelParts.push(\"⌫\");\n      } else if (p === \"Delete\") {\n        labelParts.push(\"⌦\");\n      } else if (p === \"Minus\") {\n        labelParts.push(\"-\");\n      } else if (p === \"Plus\") {\n        labelParts.push(\"+\");\n      } else if (p === \"Equal\") {\n        labelParts.push(\"=\");\n      } else if (p === \"Space\") {\n        labelParts.push(\"Space\");\n      } else {\n        labelParts.push(capitalize(p));\n      }\n    } else {\n      if (p === \"Control\") {\n        labelParts.push(\"Ctrl\");\n      } else if (p === \"Space\") {\n        labelParts.push(\"Space\");\n      } else {\n        labelParts.push(capitalize(p));\n      }\n    }\n  }\n\n  if (os === \"macos\") {\n    return labelParts;\n  }\n  return [labelParts.join(\"+\")];\n}\n\nexport function useFormattedHotkey(action: HotkeyAction | null): string[] | null {\n  const hotkeys = useAtomValue(hotkeysAtom);\n  const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null;\n  if (trigger == null) {\n    return null;\n  }\n\n  return formatHotkeyString(trigger);\n}\n\nfunction compareKeys(keysA: string[], keysB: string[]) {\n  if (keysA.length !== keysB.length) return false;\n  const sortedA = keysA\n    .map((k) => k.toLowerCase())\n    .sort()\n    .join(\"::\");\n  const sortedB = keysB\n    .map((k) => k.toLowerCase())\n    .sort()\n    .join(\"::\");\n  return sortedA === sortedB;\n}\n\n/** Build the full key combination from a KeyboardEvent including modifiers */\nfunction getKeysFromEvent(e: KeyboardEvent): string[] {\n  const keys: string[] = [];\n  if (e.altKey) keys.push(\"Alt\");\n  if (e.ctrlKey) keys.push(\"Control\");\n  if (e.metaKey) keys.push(\"Meta\");\n  if (e.shiftKey) keys.push(\"Shift\");\n\n  // Add the actual key (use code for layout-insensitive keys)\n  const keyToAdd = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;\n  keys.push(keyToAdd);\n\n  return keys;\n}\n\n/** Check if a set of pressed keys matches any hotkey for the given action */\nfunction keysMatchAction(keys: string[], action: HotkeyAction): boolean {\n  const hotkeys = getHotkeys();\n  const hkKeys = hotkeys[action];\n  if (!hkKeys || hkKeys.length === 0) return false;\n\n  for (const hkKey of hkKeys) {\n    const hotkeyParts = hkKey.split(\"+\");\n    if (compareKeys(hotkeyParts, keys)) {\n      return true;\n    }\n  }\n  return false;\n}\n\n/** Check if a KeyboardEvent matches a hotkey action */\nexport function eventMatchesHotkey(e: KeyboardEvent, action: HotkeyAction): boolean {\n  const keys = getKeysFromEvent(e);\n  return keysMatchAction(keys, action);\n}\n"
  },
  {
    "path": "src-web/hooks/useHttpAuthentication.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type { GetHttpAuthenticationSummaryResponse } from \"@yaakapp-internal/plugins\";\nimport { atom, useAtomValue } from \"jotai\";\nimport { useState } from \"react\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { showErrorToast } from \"../lib/toast\";\nimport { usePluginsKey } from \"./usePlugins\";\n\nconst httpAuthenticationSummariesAtom = atom<GetHttpAuthenticationSummaryResponse[]>([]);\nconst orderedHttpAuthenticationAtom = atom((get) =>\n  get(httpAuthenticationSummariesAtom)?.sort((a, b) => a.name.localeCompare(b.name)),\n);\n\nexport function useHttpAuthenticationSummaries() {\n  return useAtomValue(orderedHttpAuthenticationAtom);\n}\n\nexport function useSubscribeHttpAuthentication() {\n  const [numResults, setNumResults] = useState<number>(0);\n  const pluginsKey = usePluginsKey();\n\n  useQuery({\n    queryKey: [\"http_authentication_summaries\", pluginsKey],\n    // Fetch periodically until functions are returned\n    // NOTE: visibilitychange (refetchOnWindowFocus) does not work on Windows, so we'll rely on this logic\n    //  to refetch things until that's working again\n    // TODO: Update plugin system to wait for plugins to initialize before sending the first event to them\n    refetchInterval: numResults > 0 ? Number.POSITIVE_INFINITY : 1000,\n    refetchOnMount: true,\n    placeholderData: (prev) => prev, // Keep previous data on refetch\n    queryFn: async () => {\n      try {\n        const result = await invokeCmd<GetHttpAuthenticationSummaryResponse[]>(\n          \"cmd_get_http_authentication_summaries\",\n        );\n        setNumResults(result.length);\n        jotaiStore.set(httpAuthenticationSummariesAtom, result);\n        return result;\n      } catch (err) {\n        showErrorToast({\n          id: \"http-authentication-error\",\n          title: \"HTTP Authentication Error\",\n          message: err,\n        });\n      }\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useHttpAuthenticationConfig.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type {\n  Folder,\n  GrpcRequest,\n  HttpRequest,\n  WebsocketRequest,\n  Workspace,\n} from \"@yaakapp-internal/models\";\nimport { httpResponsesAtom } from \"@yaakapp-internal/models\";\nimport type { GetHttpAuthenticationConfigResponse, JsonPrimitive } from \"@yaakapp-internal/plugins\";\nimport { useAtomValue } from \"jotai\";\nimport { md5 } from \"js-md5\";\nimport { useState } from \"react\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { activeEnvironmentIdAtom } from \"./useActiveEnvironment\";\nimport { activeWorkspaceIdAtom } from \"./useActiveWorkspace\";\n\nexport function useHttpAuthenticationConfig(\n  authName: string | null,\n  values: Record<string, JsonPrimitive>,\n  model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,\n) {\n  const workspaceId = useAtomValue(activeWorkspaceIdAtom);\n  const environmentId = useAtomValue(activeEnvironmentIdAtom);\n  const responses = useAtomValue(httpResponsesAtom);\n  const [forceRefreshCounter, setForceRefreshCounter] = useState<number>(0);\n\n  // Some auth handlers like OAuth 2.0 show the current token after a successful request. To\n  // handle that, we'll force the auth to re-fetch after each new response closes\n  const responseKey = md5(\n    responses\n      .filter((r) => r.state === \"closed\")\n      .map((r) => r.id)\n      .join(\":\"),\n  );\n\n  return useQuery({\n    queryKey: [\n      \"http_authentication_config\",\n      model,\n      authName,\n      values,\n      responseKey,\n      forceRefreshCounter,\n      workspaceId,\n      environmentId,\n    ],\n    placeholderData: (prev) => prev, // Keep previous data on refetch\n    queryFn: async () => {\n      if (authName == null || authName === \"inherit\") return null;\n      const config = await invokeCmd<GetHttpAuthenticationConfigResponse>(\n        \"cmd_get_http_authentication_config\",\n        {\n          authName,\n          values,\n          model,\n          environmentId,\n        },\n      );\n\n      return {\n        ...config,\n        actions: config.actions?.map((a, i) => ({\n          ...a,\n          call: async (\n            model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,\n          ) => {\n            await invokeCmd(\"cmd_call_http_authentication_action\", {\n              pluginRefId: config.pluginRefId,\n              actionIndex: i,\n              authName,\n              values,\n              model,\n              environmentId,\n            });\n\n            // Ensure the config is refreshed after the action is done\n            setForceRefreshCounter((c) => c + 1);\n          },\n        })),\n      };\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useHttpRequestActions.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type { HttpRequest } from \"@yaakapp-internal/models\";\nimport type {\n  CallHttpRequestActionRequest,\n  GetHttpRequestActionsResponse,\n  HttpRequestAction,\n} from \"@yaakapp-internal/plugins\";\nimport { useMemo } from \"react\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { usePluginsKey } from \"./usePlugins\";\n\nexport type CallableHttpRequestAction = Pick<HttpRequestAction, \"label\" | \"icon\"> & {\n  call: (httpRequest: HttpRequest) => Promise<void>;\n};\n\nexport function useHttpRequestActions() {\n  const pluginsKey = usePluginsKey();\n\n  const actionsResult = useQuery<CallableHttpRequestAction[]>({\n    queryKey: [\"http_request_actions\", pluginsKey],\n    queryFn: () => getHttpRequestActions(),\n  });\n\n  // oxlint-disable-next-line react-hooks/exhaustive-deps\n  const actions = useMemo(() => {\n    return actionsResult.data ?? [];\n  }, [JSON.stringify(actionsResult.data)]);\n\n  return actions;\n}\n\nexport async function getHttpRequestActions() {\n  const responses = await invokeCmd<GetHttpRequestActionsResponse[]>(\"cmd_http_request_actions\");\n  const actions = responses.flatMap((r) =>\n    r.actions.map((a, i) => ({\n      label: a.label,\n      icon: a.icon,\n      call: async (httpRequest: HttpRequest) => {\n        const payload: CallHttpRequestActionRequest = {\n          index: i,\n          pluginRefId: r.pluginRefId,\n          args: { httpRequest },\n        };\n        await invokeCmd(\"cmd_call_http_request_action\", { req: payload });\n      },\n    })),\n  );\n\n  return actions;\n}\n"
  },
  {
    "path": "src-web/hooks/useHttpRequestBody.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { invokeCmd } from \"../lib/tauri\";\n\nexport function useHttpRequestBody(response: HttpResponse | null) {\n  return useQuery({\n    placeholderData: (prev) => prev, // Keep previous data on refetch\n    queryKey: [\"request_body\", response?.id, response?.state, response?.requestContentLength],\n    enabled: (response?.requestContentLength ?? 0) > 0,\n    queryFn: async () => {\n      return getRequestBodyText(response);\n    },\n  });\n}\n\nexport async function getRequestBodyText(response: HttpResponse | null) {\n  if (response?.id == null) {\n    return null;\n  }\n\n  const data = await invokeCmd<number[] | null>(\"cmd_http_request_body\", {\n    responseId: response.id,\n  });\n\n  if (data == null) {\n    return null;\n  }\n\n  const body = new Uint8Array(data);\n  const bodyText = new TextDecoder(\"utf-8\", { fatal: false }).decode(body);\n  return { body, bodyText };\n}\n"
  },
  {
    "path": "src-web/hooks/useHttpResponseEvents.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type { HttpResponse, HttpResponseEvent } from \"@yaakapp-internal/models\";\nimport {\n  httpResponseEventsAtom,\n  mergeModelsInStore,\n  replaceModelsInStore,\n} from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useEffect } from \"react\";\nimport { fireAndForget } from \"../lib/fireAndForget\";\n\nexport function useHttpResponseEvents(response: HttpResponse | null) {\n  const allEvents = useAtomValue(httpResponseEventsAtom);\n\n  useEffect(() => {\n    if (response?.id == null) {\n      replaceModelsInStore(\"http_response_event\", []);\n      return;\n    }\n\n    // Fetch events from database, filtering out events from other responses and merging atomically\n    fireAndForget(\n      invoke<HttpResponseEvent[]>(\"cmd_get_http_response_events\", { responseId: response.id }).then(\n        (events) =>\n          mergeModelsInStore(\"http_response_event\", events, (e) => e.responseId === response.id),\n      ),\n    );\n  }, [response?.id]);\n\n  const events = allEvents.filter((e) => e.responseId === response?.id);\n  return { data: events, error: null, isLoading: false };\n}\n"
  },
  {
    "path": "src-web/hooks/useImportCurl.ts",
    "content": "import type { HttpRequest } from \"@yaakapp-internal/models\";\nimport { patchModelById } from \"@yaakapp-internal/models\";\nimport { createRequestAndNavigate } from \"../lib/createRequestAndNavigate\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { showToast } from \"../lib/toast\";\nimport { activeWorkspaceIdAtom } from \"./useActiveWorkspace\";\nimport { useFastMutation } from \"./useFastMutation\";\nimport { wasUpdatedExternally } from \"./useRequestUpdateKey\";\n\nexport function useImportCurl() {\n  return useFastMutation({\n    mutationKey: [\"import_curl\"],\n    mutationFn: async ({\n      overwriteRequestId,\n      command,\n    }: {\n      overwriteRequestId?: string;\n      command: string;\n    }) => {\n      const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n      const importedRequest: HttpRequest = await invokeCmd(\"cmd_curl_to_request\", {\n        command,\n        workspaceId,\n      });\n\n      let verb: string;\n      if (overwriteRequestId == null) {\n        verb = \"Created\";\n        await createRequestAndNavigate(importedRequest);\n      } else {\n        verb = \"Updated\";\n        await patchModelById(importedRequest.model, overwriteRequestId, (r: HttpRequest) => ({\n          ...importedRequest,\n          id: r.id,\n          createdAt: r.createdAt,\n          workspaceId: r.workspaceId,\n          folderId: r.folderId,\n          name: r.name,\n          sortPriority: r.sortPriority,\n        }));\n\n        setTimeout(() => wasUpdatedExternally(overwriteRequestId), 100);\n      }\n\n      showToast({\n        color: \"success\",\n        message: `${verb} request from Curl`,\n      });\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useInheritedAuthentication.ts",
    "content": "import type {\n  Folder,\n  GrpcRequest,\n  HttpRequest,\n  WebsocketRequest,\n  Workspace,\n} from \"@yaakapp-internal/models\";\nimport { foldersAtom, workspacesAtom } from \"@yaakapp-internal/models\";\nimport { atom, useAtomValue } from \"jotai\";\n\nconst ancestorsAtom = atom((get) => [...get(foldersAtom), ...get(workspacesAtom)]);\n\nexport type AuthenticatedModel = HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;\n\nexport function useInheritedAuthentication(baseModel: AuthenticatedModel | null) {\n  const parents = useAtomValue(ancestorsAtom);\n\n  if (baseModel == null) return null;\n\n  const next = (child: AuthenticatedModel) => {\n    // We hit the top\n    if (child.model === \"workspace\") {\n      return child.authenticationType == null ? null : child;\n    }\n\n    // Has valid auth\n    if (child.authenticationType !== null) {\n      return child;\n    }\n\n    // Recurse up the tree\n    const parent = parents.find((p) => {\n      if (child.folderId) return p.id === child.folderId;\n      return p.id === child.workspaceId;\n    });\n\n    // Failed to find parent (should never happen)\n    if (parent == null) {\n      return null;\n    }\n\n    return next(parent);\n  };\n\n  return next(baseModel);\n}\n"
  },
  {
    "path": "src-web/hooks/useInheritedHeaders.ts",
    "content": "import type {\n  Folder,\n  GrpcRequest,\n  HttpRequest,\n  HttpRequestHeader,\n  WebsocketRequest,\n  Workspace,\n} from \"@yaakapp-internal/models\";\nimport { foldersAtom, workspacesAtom } from \"@yaakapp-internal/models\";\nimport { atom, useAtomValue } from \"jotai\";\nimport { defaultHeaders } from \"../lib/defaultHeaders\";\n\nconst ancestorsAtom = atom((get) => [...get(foldersAtom), ...get(workspacesAtom)]);\n\nexport type HeaderModel = HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;\n\nexport function useInheritedHeaders(baseModel: HeaderModel | null) {\n  const parents = useAtomValue(ancestorsAtom);\n\n  if (baseModel == null) return [];\n  if (baseModel.model === \"workspace\") return defaultHeaders;\n\n  const next = (child: HeaderModel): HttpRequestHeader[] => {\n    // Short-circuit at workspace level - return global defaults + workspace headers\n    if (child.model === \"workspace\") {\n      return [...defaultHeaders, ...child.headers];\n    }\n\n    // Recurse up the tree\n    const parent = parents.find((p) => {\n      if (child.folderId) return p.id === child.folderId;\n      return p.id === child.workspaceId;\n    });\n\n    // Failed to find parent (should never happen)\n    if (parent == null) {\n      return [];\n    }\n\n    const headers = next(parent);\n    return [...headers, ...parent.headers];\n  };\n\n  const allHeaders = next(baseModel);\n\n  // Deduplicate by header name (case-insensitive), keeping the latest (most specific) value\n  const headersByName = new Map<string, HttpRequestHeader>();\n  for (const header of allHeaders) {\n    headersByName.set(header.name.toLowerCase(), header);\n  }\n\n  return Array.from(headersByName.values());\n}\n"
  },
  {
    "path": "src-web/hooks/useInstallPlugin.ts",
    "content": "import { installPluginFromDirectory } from \"@yaakapp-internal/plugins\";\nimport { useFastMutation } from \"./useFastMutation\";\n\nexport function useInstallPlugin() {\n  return useFastMutation<void, unknown, string>({\n    mutationKey: [\"install_plugin\"],\n    mutationFn: async (directory: string) => {\n      await installPluginFromDirectory(directory);\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useIntrospectGraphQL.ts",
    "content": "import { useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport type { GraphQlIntrospection, HttpRequest } from \"@yaakapp-internal/models\";\nimport type { GraphQLSchema, IntrospectionQuery } from \"graphql\";\nimport { buildClientSchema, getIntrospectionQuery } from \"graphql\";\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\nimport { minPromiseMillis } from \"../lib/minPromiseMillis\";\nimport { getResponseBodyText } from \"../lib/responseBody\";\nimport { sendEphemeralRequest } from \"../lib/sendEphemeralRequest\";\nimport { useActiveEnvironment } from \"./useActiveEnvironment\";\nimport { useDebouncedValue } from \"./useDebouncedValue\";\n\nconst introspectionRequestBody = JSON.stringify({\n  query: getIntrospectionQuery(),\n  operationName: \"IntrospectionQuery\",\n});\n\nexport function useIntrospectGraphQL(\n  baseRequest: HttpRequest,\n  options: { disabled?: boolean } = {},\n) {\n  // Debounce the request because it can change rapidly, and we don't\n  // want to send so too many requests.\n  const debouncedRequest = useDebouncedValue(baseRequest);\n  const activeEnvironment = useActiveEnvironment();\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [error, setError] = useState<string>();\n  const [schema, setSchema] = useState<GraphQLSchema | null>(null);\n  const queryClient = useQueryClient();\n\n  const introspection = useIntrospectionResult(baseRequest);\n\n  const upsertIntrospection = useCallback(\n    async (content: string | null) => {\n      const v = await invoke<GraphQlIntrospection>(\"models_upsert_graphql_introspection\", {\n        requestId: baseRequest.id,\n        workspaceId: baseRequest.workspaceId,\n        content: content ?? \"\",\n      });\n\n      // Update local introspection\n      queryClient.setQueryData([\"introspection\", baseRequest.id], v);\n    },\n    [baseRequest.id, baseRequest.workspaceId, queryClient],\n  );\n\n  const refetch = useCallback(async () => {\n    try {\n      setIsLoading(true);\n      setError(undefined);\n\n      const args = {\n        ...baseRequest,\n        bodyType: \"application/json\",\n        body: { text: introspectionRequestBody },\n      };\n      const response = await minPromiseMillis(\n        sendEphemeralRequest(args, activeEnvironment?.id ?? null),\n        700,\n      );\n\n      if (response.error) {\n        return setError(response.error);\n      }\n\n      const bodyText = await getResponseBodyText({ response, filter: null });\n      if (response.status < 200 || response.status >= 300) {\n        return setError(\n          `Request failed with status ${response.status}.\\nThe response text is:\\n\\n${bodyText}`,\n        );\n      }\n\n      if (bodyText === null) {\n        return setError(\"Empty body returned in response\");\n      }\n\n      console.log(`Got introspection response for ${baseRequest.url}`, bodyText);\n      await upsertIntrospection(bodyText);\n    } catch (err) {\n      setError(String(err));\n    } finally {\n      setIsLoading(false);\n    }\n  }, [activeEnvironment?.id, baseRequest, upsertIntrospection]);\n\n  // oxlint-disable-next-line react-hooks/exhaustive-deps\n  useEffect(() => {\n    // Skip introspection if automatic is disabled and we already have one\n    if (options.disabled) {\n      return;\n    }\n\n    refetch().catch(console.error);\n  }, [baseRequest.id, debouncedRequest.url, debouncedRequest.method, activeEnvironment?.id]);\n\n  const clear = useCallback(async () => {\n    setError(\"\");\n    setSchema(null);\n    await upsertIntrospection(null);\n  }, [upsertIntrospection]);\n\n  useEffect(() => {\n    if (introspection.data?.content == null || introspection.data.content === \"\") {\n      return;\n    }\n\n    const parseResult = tryParseIntrospectionToSchema(introspection.data.content);\n    if (\"error\" in parseResult) {\n      setError(parseResult.error);\n    } else {\n      setSchema(parseResult.schema);\n    }\n  }, [introspection.data?.content]);\n\n  return { schema, isLoading, error, refetch, clear };\n}\n\nfunction useIntrospectionResult(request: HttpRequest) {\n  return useQuery({\n    queryKey: [\"introspection\", request.id],\n    queryFn: async () =>\n      invoke<GraphQlIntrospection | null>(\"models_get_graphql_introspection\", {\n        requestId: request.id,\n      }),\n  });\n}\n\nexport function useCurrentGraphQLSchema(request: HttpRequest) {\n  const result = useIntrospectionResult(request);\n  return useMemo(() => {\n    if (result.data == null) return null;\n    if (result.data.content == null || result.data.content === \"\") return null;\n    const r = tryParseIntrospectionToSchema(result.data.content);\n    return \"error\" in r ? null : r.schema;\n  }, [result.data]);\n}\n\nfunction tryParseIntrospectionToSchema(\n  content: string,\n): { schema: GraphQLSchema } | { error: string } {\n  let parsedResponse: IntrospectionQuery;\n  try {\n    parsedResponse = JSON.parse(content).data;\n    // oxlint-disable-next-line no-explicit-any\n  } catch (e: any) {\n    return { error: String(\"message\" in e ? e.message : e) };\n  }\n\n  try {\n    return { schema: buildClientSchema(parsedResponse, {}) };\n    // oxlint-disable-next-line no-explicit-any\n  } catch (e: any) {\n    return { error: String(\"message\" in e ? e.message : e) };\n  }\n}\n"
  },
  {
    "path": "src-web/hooks/useIsEncryptionEnabled.ts",
    "content": "import { useAtomValue } from \"jotai\";\nimport { activeWorkspaceMetaAtom } from \"./useActiveWorkspace\";\n\nexport function useIsEncryptionEnabled() {\n  const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);\n  return workspaceMeta?.encryptionKey != null;\n}\n"
  },
  {
    "path": "src-web/hooks/useIsFullscreen.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport { getCurrentWebviewWindow } from \"@tauri-apps/api/webviewWindow\";\nimport { useWindowSize } from \"react-use\";\nimport { useDebouncedValue } from \"./useDebouncedValue\";\n\nexport function useIsFullscreen() {\n  const windowSize = useWindowSize();\n  const debouncedWindowWidth = useDebouncedValue(windowSize.width);\n\n  // NOTE: Fullscreen state isn't updated right after resize event on Mac (needs to wait for animation) so\n  // we'll wait for a bit using the debounced window size. Hopefully Tauri eventually adds a way to listen\n  // for fullscreen change events.\n\n  return (\n    useQuery({\n      queryKey: [\"is_fullscreen\", debouncedWindowWidth],\n      queryFn: async () => {\n        return getCurrentWebviewWindow().isFullscreen();\n      },\n    }).data ?? false\n  );\n}\n"
  },
  {
    "path": "src-web/hooks/useKeyValue.ts",
    "content": "import deepEqual from \"@gilbarbara/deep-equal\";\nimport { useMutation } from \"@tanstack/react-query\";\nimport { keyValuesAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { selectAtom } from \"jotai/utils\";\nimport { useCallback, useMemo } from \"react\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { buildKeyValueKey, extractKeyValueOrFallback, setKeyValue } from \"../lib/keyValueStore\";\n\nconst DEFAULT_NAMESPACE = \"global\";\n\nexport function useKeyValue<T extends object | boolean | number | string | null>({\n  namespace = DEFAULT_NAMESPACE,\n  key,\n  fallback,\n}: {\n  namespace?: \"global\" | \"no_sync\" | \"license\";\n  key: string | string[];\n  fallback: T;\n}) {\n  const { value, isLoading } = useAtomValue(\n    // oxlint-disable-next-line react-hooks/exhaustive-deps -- Only create a new atom when the key changes. Fallback might not be a stable reference, so we don't want to refresh on that.\n    useMemo(\n      () =>\n        selectAtom(\n          keyValuesAtom,\n          (keyValues) => {\n            const keyValue =\n              keyValues?.find((kv) => buildKeyValueKey(kv.key) === buildKeyValueKey(key)) ?? null;\n            const value = keyValues == null ? null : extractKeyValueOrFallback(keyValue, fallback);\n            const isLoading = keyValues == null;\n            return { value, isLoading };\n          },\n          (a, b) => deepEqual(a, b),\n        ),\n      [buildKeyValueKey(key)],\n    ),\n  );\n\n  const { mutateAsync } = useMutation<void, unknown, T>({\n    mutationKey: [\"set_key_value\", namespace, key],\n    mutationFn: (value) => setKeyValue<T>({ namespace, key, value }),\n  });\n\n  // oxlint-disable-next-line react-hooks/exhaustive-deps\n  const set = useCallback(\n    async (valueOrUpdate: ((v: T) => T) | T) => {\n      if (typeof valueOrUpdate === \"function\") {\n        const newV = valueOrUpdate(value ?? fallback);\n        if (newV === value) return;\n        await mutateAsync(newV);\n      } else {\n        // TODO: Make this only update if the value is different. I tried this but it seems query.data\n        //  is stale.\n        await mutateAsync(valueOrUpdate);\n      }\n    },\n    [typeof key === \"string\" ? key : key.join(\"::\"), namespace, value],\n  );\n\n  const reset = useCallback(async () => mutateAsync(fallback), [fallback, mutateAsync]);\n\n  return useMemo(\n    () => ({\n      value,\n      isLoading,\n      set,\n      reset,\n    }),\n    [isLoading, reset, set, value],\n  );\n}\n\nexport function getKeyValue<T extends object | boolean | number | string | null>({\n  namespace,\n  key,\n  fallback,\n}: {\n  namespace?: \"global\" | \"no_sync\" | \"license\";\n  key: string | string[];\n  fallback: T;\n}) {\n  const keyValues = jotaiStore.get(keyValuesAtom);\n  const keyValue =\n    keyValues?.find(\n      (kv) => kv.namespace === namespace && buildKeyValueKey(kv.key) === buildKeyValueKey(key),\n    ) ?? null;\n  const value = extractKeyValueOrFallback(keyValue, fallback);\n  return value;\n}\n"
  },
  {
    "path": "src-web/hooks/useKeyboardEvent.ts",
    "content": "import { useEffect } from \"react\";\n\nexport function useKeyboardEvent(\n  event: \"keyup\" | \"keydown\",\n  key: KeyboardEvent[\"key\"],\n  cb: () => void,\n) {\n  // oxlint-disable-next-line react-hooks/exhaustive-deps -- Don't have `cb` as a dep for caller convenience\n  useEffect(() => {\n    const fn = (e: KeyboardEvent) => {\n      if (e.key === key) cb();\n    };\n    document.addEventListener(event, fn);\n    return () => document.removeEventListener(event, fn);\n  }, [event]);\n}\n"
  },
  {
    "path": "src-web/hooks/useLatestGrpcConnection.ts",
    "content": "import type { GrpcConnection } from \"@yaakapp-internal/models\";\nimport { grpcConnectionsAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\n\nexport function useLatestGrpcConnection(requestId: string | null): GrpcConnection | null {\n  return useAtomValue(grpcConnectionsAtom).find((c) => c.requestId === requestId) ?? null;\n}\n"
  },
  {
    "path": "src-web/hooks/useLatestHttpResponse.ts",
    "content": "import type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { httpResponsesAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\n\nexport function useLatestHttpResponse(requestId: string | null): HttpResponse | null {\n  return useAtomValue(httpResponsesAtom).find((r) => r.requestId === requestId) ?? null;\n}\n"
  },
  {
    "path": "src-web/hooks/useListenToTauriEvent.ts",
    "content": "import type { EventCallback, EventName } from \"@tauri-apps/api/event\";\nimport { listen } from \"@tauri-apps/api/event\";\nimport { getCurrentWebviewWindow } from \"@tauri-apps/api/webviewWindow\";\nimport { useEffect, useRef } from \"react\";\n\nexport function useListenToTauriEvent<T>(event: EventName, fn: EventCallback<T>) {\n  const handlerRef = useRef(fn);\n  useEffect(() => {\n    handlerRef.current = fn;\n  }, [fn]);\n\n  useEffect(() => {\n    return listenToTauriEvent<T>(event, (p) => handlerRef.current(p));\n  }, [event]);\n}\n\nexport function listenToTauriEvent<T>(event: EventName, fn: EventCallback<T>) {\n  const unsubPromise = listen<T>(\n    event,\n    fn,\n    // Listen to `emit_all()` events or events specific to the current window\n    { target: { label: getCurrentWebviewWindow().label, kind: \"Window\" } },\n  );\n\n  return () => {\n    unsubPromise.then((unsub) => unsub()).catch(console.error);\n  };\n}\n"
  },
  {
    "path": "src-web/hooks/useModelAncestors.ts",
    "content": "import type { AnyModel, Folder, Workspace } from \"@yaakapp-internal/models\";\nimport { foldersAtom, workspacesAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useMemo } from \"react\";\n\ntype ModelAncestor = Folder | Workspace;\n\nexport function useModelAncestors(m: AnyModel | null) {\n  const folders = useAtomValue(foldersAtom);\n  const workspaces = useAtomValue(workspacesAtom);\n\n  return useMemo(() => getModelAncestors(folders, workspaces, m), [folders, workspaces, m]);\n}\n\nexport function getModelAncestors(\n  folders: Folder[],\n  workspaces: Workspace[],\n  currentModel: AnyModel | null,\n): ModelAncestor[] {\n  if (currentModel == null) return [];\n\n  const parentFolder =\n    \"folderId\" in currentModel && currentModel.folderId\n      ? folders.find((f) => f.id === currentModel.folderId)\n      : null;\n\n  if (parentFolder != null) {\n    return [parentFolder, ...getModelAncestors(folders, workspaces, parentFolder)];\n  }\n\n  const parentWorkspace =\n    \"workspaceId\" in currentModel && currentModel.workspaceId\n      ? workspaces.find((w) => w.id === currentModel.workspaceId)\n      : null;\n\n  if (parentWorkspace != null) {\n    return [parentWorkspace, ...getModelAncestors(folders, workspaces, parentWorkspace)];\n  }\n\n  return [];\n}\n"
  },
  {
    "path": "src-web/hooks/useParentFolders.ts",
    "content": "import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from \"@yaakapp-internal/models\";\nimport { foldersAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useMemo } from \"react\";\n\nexport function useParentFolders(m: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null) {\n  const folders = useAtomValue(foldersAtom);\n\n  return useMemo(() => getParentFolders(folders, m), [folders, m]);\n}\n\nfunction getParentFolders(\n  folders: Folder[],\n  currentModel: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,\n): Folder[] {\n  if (currentModel == null) return [];\n\n  const parentFolder = currentModel.folderId\n    ? folders.find((f) => f.id === currentModel.folderId)\n    : null;\n  if (parentFolder == null) {\n    return [];\n  }\n\n  return [parentFolder, ...getParentFolders(folders, parentFolder)];\n}\n"
  },
  {
    "path": "src-web/hooks/usePinnedGrpcConnection.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type { GrpcConnection, GrpcEvent } from \"@yaakapp-internal/models\";\nimport {\n  grpcConnectionsAtom,\n  grpcEventsAtom,\n  mergeModelsInStore,\n  replaceModelsInStore,\n} from \"@yaakapp-internal/models\";\nimport { atom, useAtomValue } from \"jotai\";\nimport { useEffect, useMemo } from \"react\";\nimport { fireAndForget } from \"../lib/fireAndForget\";\nimport { atomWithKVStorage } from \"../lib/atoms/atomWithKVStorage\";\nimport { activeRequestIdAtom } from \"./useActiveRequestId\";\n\nconst pinnedGrpcConnectionIdsAtom = atomWithKVStorage<Record<string, string | null>>(\n  \"pinned-grpc-connection-ids\",\n  {},\n);\n\nexport const pinnedGrpcConnectionIdAtom = atom(\n  (get) => {\n    const activeRequestId = get(activeRequestIdAtom);\n    const activeConnections = get(activeGrpcConnections);\n    const latestConnection = activeConnections[0] ?? null;\n    if (!activeRequestId) return null;\n\n    const key = recordKey(activeRequestId, latestConnection);\n    return get(pinnedGrpcConnectionIdsAtom)[key] ?? null;\n  },\n  (get, set, id: string | null) => {\n    const activeRequestId = get(activeRequestIdAtom);\n    const activeConnections = get(activeGrpcConnections);\n    const latestConnection = activeConnections[0] ?? null;\n    if (!activeRequestId) return;\n\n    const key = recordKey(activeRequestId, latestConnection);\n    set(pinnedGrpcConnectionIdsAtom, (prev) => ({\n      ...prev,\n      [key]: id,\n    }));\n  },\n);\n\nfunction recordKey(activeRequestId: string | null, latestConnection: GrpcConnection | null) {\n  return `${activeRequestId}-${latestConnection?.id ?? \"none\"}`;\n}\n\nexport const activeGrpcConnections = atom<GrpcConnection[]>((get) => {\n  const activeRequestId = get(activeRequestIdAtom) ?? \"n/a\";\n  return get(grpcConnectionsAtom).filter((c) => c.requestId === activeRequestId) ?? [];\n});\n\nexport const activeGrpcConnectionAtom = atom<GrpcConnection | null>((get) => {\n  const activeRequestId = get(activeRequestIdAtom) ?? \"n/a\";\n  const activeConnections = get(activeGrpcConnections);\n  const latestConnection = activeConnections[0] ?? null;\n  const pinnedConnectionId = get(pinnedGrpcConnectionIdsAtom)[\n    recordKey(activeRequestId, latestConnection)\n  ];\n  return activeConnections.find((c) => c.id === pinnedConnectionId) ?? activeConnections[0] ?? null;\n});\n\nexport function useGrpcEvents(connectionId: string | null) {\n  const allEvents = useAtomValue(grpcEventsAtom);\n\n  useEffect(() => {\n    if (connectionId == null) {\n      replaceModelsInStore(\"grpc_event\", []);\n      return;\n    }\n\n    // Fetch events from database, filtering out events from other connections and merging atomically\n    fireAndForget(\n      invoke<GrpcEvent[]>(\"models_grpc_events\", { connectionId }).then((events) =>\n        mergeModelsInStore(\"grpc_event\", events, (e) => e.connectionId === connectionId),\n      ),\n    );\n  }, [connectionId]);\n\n  return useMemo(\n    () => allEvents.filter((e) => e.connectionId === connectionId),\n    [allEvents, connectionId],\n  );\n}\n"
  },
  {
    "path": "src-web/hooks/usePinnedHttpResponse.ts",
    "content": "import type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { httpResponsesAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useKeyValue } from \"./useKeyValue\";\nimport { useLatestHttpResponse } from \"./useLatestHttpResponse\";\n\nexport function usePinnedHttpResponse(activeRequestId: string) {\n  const latestResponse = useLatestHttpResponse(activeRequestId);\n  const { set, value: pinnedResponseId } = useKeyValue<string | null>({\n    // Key on the latest response instead of activeRequest because responses change out of band of active request\n    key: [\"pinned_http_response_id\", latestResponse?.id ?? \"n/a\"],\n    fallback: null,\n    namespace: \"global\",\n  });\n  const allResponses = useAtomValue(httpResponsesAtom);\n  const responses = allResponses.filter((r) => r.requestId === activeRequestId);\n  const activeResponse: HttpResponse | null =\n    responses.find((r) => r.id === pinnedResponseId) ?? latestResponse;\n\n  const setPinnedResponseId = async (id: string) => {\n    if (pinnedResponseId === id) {\n      await set(null);\n    } else {\n      await set(id);\n    }\n  };\n\n  return { activeResponse, setPinnedResponseId, pinnedResponseId, responses } as const;\n}\n"
  },
  {
    "path": "src-web/hooks/usePinnedWebsocketConnection.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type { WebsocketConnection, WebsocketEvent } from \"@yaakapp-internal/models\";\nimport {\n  mergeModelsInStore,\n  replaceModelsInStore,\n  websocketConnectionsAtom,\n  websocketEventsAtom,\n} from \"@yaakapp-internal/models\";\nimport { atom, useAtomValue } from \"jotai\";\nimport { useEffect, useMemo } from \"react\";\nimport { fireAndForget } from \"../lib/fireAndForget\";\nimport { atomWithKVStorage } from \"../lib/atoms/atomWithKVStorage\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { activeRequestIdAtom } from \"./useActiveRequestId\";\n\nconst pinnedWebsocketConnectionIdAtom = atomWithKVStorage<Record<string, string | null>>(\n  \"pinned-websocket-connection-ids\",\n  {},\n);\n\nfunction recordKey(activeRequestId: string | null, latestConnection: WebsocketConnection | null) {\n  return `${activeRequestId}-${latestConnection?.id ?? \"none\"}`;\n}\n\nexport const activeWebsocketConnectionsAtom = atom<WebsocketConnection[]>((get) => {\n  const activeRequestId = get(activeRequestIdAtom) ?? \"n/a\";\n  return get(websocketConnectionsAtom).filter((c) => c.requestId === activeRequestId) ?? [];\n});\n\nexport const activeWebsocketConnectionAtom = atom<WebsocketConnection | null>((get) => {\n  const activeRequestId = get(activeRequestIdAtom) ?? \"n/a\";\n  const activeConnections = get(activeWebsocketConnectionsAtom);\n  const latestConnection = activeConnections[0] ?? null;\n  const pinnedConnectionId = get(pinnedWebsocketConnectionIdAtom)[\n    recordKey(activeRequestId, latestConnection)\n  ];\n  return activeConnections.find((c) => c.id === pinnedConnectionId) ?? activeConnections[0] ?? null;\n});\n\nexport function setPinnedWebsocketConnectionId(id: string | null) {\n  const activeRequestId = jotaiStore.get(activeRequestIdAtom);\n  const activeConnections = jotaiStore.get(activeWebsocketConnectionsAtom);\n  const latestConnection = activeConnections[0] ?? null;\n  if (activeRequestId == null) return;\n  jotaiStore.set(pinnedWebsocketConnectionIdAtom, (prev) => {\n    return { ...prev, [recordKey(activeRequestId, latestConnection)]: id };\n  });\n}\n\nexport function useWebsocketEvents(connectionId: string | null) {\n  const allEvents = useAtomValue(websocketEventsAtom);\n\n  useEffect(() => {\n    if (connectionId == null) {\n      replaceModelsInStore(\"websocket_event\", []);\n      return;\n    }\n\n    // Fetch events from database, filtering out events from other connections and merging atomically\n    fireAndForget(\n      invoke<WebsocketEvent[]>(\"models_websocket_events\", { connectionId }).then((events) =>\n        mergeModelsInStore(\"websocket_event\", events, (e) => e.connectionId === connectionId),\n      ),\n    );\n  }, [connectionId]);\n\n  return useMemo(\n    () => allEvents.filter((e) => e.connectionId === connectionId),\n    [allEvents, connectionId],\n  );\n}\n"
  },
  {
    "path": "src-web/hooks/usePluginInfo.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type { Plugin } from \"@yaakapp-internal/models\";\nimport { pluginsAtom } from \"@yaakapp-internal/models\";\nimport type { PluginMetadata } from \"@yaakapp-internal/plugins\";\nimport { useAtomValue } from \"jotai\";\nimport { queryClient } from \"../lib/queryClient\";\nimport { invokeCmd } from \"../lib/tauri\";\n\nfunction pluginInfoKey(id: string | null, plugin: Plugin | null) {\n  return [\"plugin_info\", id ?? \"n/a\", plugin?.updatedAt ?? \"n/a\"];\n}\n\nexport function usePluginInfo(id: string | null) {\n  const plugins = useAtomValue(pluginsAtom);\n  // Get the plugin so we can refetch whenever it's updated\n  const plugin = plugins.find((p) => p.id === id);\n  return useQuery({\n    queryKey: pluginInfoKey(id, plugin ?? null),\n    placeholderData: (prev) => prev, // Keep previous data on refetch\n    queryFn: () => {\n      if (id == null) return null;\n      return invokeCmd<PluginMetadata>(\"cmd_plugin_info\", { id });\n    },\n  });\n}\n\nexport function invalidateAllPluginInfo() {\n  queryClient.invalidateQueries({ queryKey: [\"plugin_info\"] }).catch(console.error);\n}\n"
  },
  {
    "path": "src-web/hooks/usePlugins.ts",
    "content": "import { useMutation } from \"@tanstack/react-query\";\nimport { changeModelStoreWorkspace, pluginsAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { minPromiseMillis } from \"../lib/minPromiseMillis\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { activeWorkspaceIdAtom } from \"./useActiveWorkspace\";\nimport { useDebouncedValue } from \"./useDebouncedValue\";\nimport { invalidateAllPluginInfo } from \"./usePluginInfo\";\n\nexport function usePluginsKey() {\n  const pluginKey = useAtomValue(pluginsAtom)\n    .map((p) => p.id + p.updatedAt)\n    .join(\",\");\n\n  // Debounce plugins both for efficiency and to give plugins a chance to reload after the DB updates\n  return useDebouncedValue(pluginKey, 1000);\n}\n\n/**\n * Reload all plugins and refresh the list of plugins\n */\nexport function useRefreshPlugins() {\n  return useMutation({\n    mutationKey: [\"refresh_plugins\"],\n    mutationFn: async () => {\n      await minPromiseMillis(\n        (async () => {\n          await invokeCmd(\"cmd_reload_plugins\");\n          const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n          await changeModelStoreWorkspace(workspaceId); // Force refresh models\n          invalidateAllPluginInfo();\n        })(),\n      );\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/usePortal.ts",
    "content": "import { useRef } from \"react\";\n\nconst PORTAL_CONTAINER_ID = \"react-portal\";\n\nexport function usePortal(name: string) {\n  const ref = useRef(getOrCreatePortal(name));\n  return ref.current;\n}\n\nfunction getOrCreatePortal(name: string) {\n  const portalContainer = document.getElementById(PORTAL_CONTAINER_ID) as HTMLDivElement;\n  let existing = portalContainer.querySelector(`:scope > [data-portal-name=\"${name}\"]`);\n  if (!existing) {\n    const el: HTMLDivElement = document.createElement(\"div\");\n    el.setAttribute(\"data-portal-name\", name);\n    portalContainer.appendChild(el);\n    existing = el;\n  }\n  return existing;\n}\n"
  },
  {
    "path": "src-web/hooks/usePreferredAppearance.ts",
    "content": "import { useEffect, useState } from \"react\";\nimport type { Appearance } from \"../lib/theme/appearance\";\nimport { getCSSAppearance, subscribeToPreferredAppearance } from \"../lib/theme/appearance\";\n\nexport function usePreferredAppearance() {\n  const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());\n  useEffect(() => subscribeToPreferredAppearance(setPreferredAppearance), []);\n  return preferredAppearance;\n}\n"
  },
  {
    "path": "src-web/hooks/useRandomKey.ts",
    "content": "import { useCallback, useState } from \"react\";\nimport { generateId } from \"../lib/generateId\";\n\nexport function useRandomKey(initialValue?: string) {\n  const [value, setValue] = useState<string>(initialValue ?? generateId());\n  const regenerate = useCallback(() => setValue(generateId()), []);\n  return [value, regenerate] as const;\n}\n"
  },
  {
    "path": "src-web/hooks/useRecentCookieJars.ts",
    "content": "import { cookieJarsAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useEffect, useMemo } from \"react\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { getKeyValue, setKeyValue } from \"../lib/keyValueStore\";\nimport { activeCookieJarAtom } from \"./useActiveCookieJar\";\nimport { useKeyValue } from \"./useKeyValue\";\n\nconst kvKey = (workspaceId: string) => `recent_cookie_jars::${workspaceId}`;\nconst namespace = \"global\";\nconst fallback: string[] = [];\n\nexport function useRecentCookieJars() {\n  const cookieJars = useAtomValue(cookieJarsAtom);\n  const kv = useKeyValue<string[]>({\n    key: kvKey(cookieJars[0]?.workspaceId ?? \"n/a\"),\n    namespace,\n    fallback,\n  });\n\n  const onlyValidIds = useMemo(\n    () => kv.value?.filter((id) => cookieJars?.some((e) => e.id === id)) ?? [],\n    [kv.value, cookieJars],\n  );\n\n  return onlyValidIds;\n}\n\nexport function useSubscribeRecentCookieJars() {\n  useEffect(() => {\n    return jotaiStore.sub(activeCookieJarAtom, async () => {\n      const activeCookieJar = jotaiStore.get(activeCookieJarAtom);\n      if (activeCookieJar == null) return;\n\n      const key = kvKey(activeCookieJar.workspaceId);\n\n      const recentIds = getKeyValue<string[]>({ namespace, key, fallback });\n      if (recentIds[0] === activeCookieJar.id) return; // Short-circuit\n\n      const withoutActiveId = recentIds.filter((id) => id !== activeCookieJar.id);\n      const value = [activeCookieJar.id, ...withoutActiveId];\n      await setKeyValue({ namespace, key, value });\n    });\n  }, []);\n}\n\nexport async function getRecentCookieJars(workspaceId: string) {\n  return getKeyValue<string[]>({\n    namespace,\n    key: kvKey(workspaceId),\n    fallback,\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useRecentEnvironments.ts",
    "content": "import { useEffect, useMemo } from \"react\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { getKeyValue, setKeyValue } from \"../lib/keyValueStore\";\nimport { activeEnvironmentAtom } from \"./useActiveEnvironment\";\nimport { useEnvironmentsBreakdown } from \"./useEnvironmentsBreakdown\";\nimport { useKeyValue } from \"./useKeyValue\";\n\nconst kvKey = (workspaceId: string) => `recent_environments::${workspaceId}`;\nconst namespace = \"global\";\nconst fallback: string[] = [];\n\nexport function useRecentEnvironments() {\n  const { subEnvironments, allEnvironments } = useEnvironmentsBreakdown();\n  const kv = useKeyValue<string[]>({\n    key: kvKey(allEnvironments[0]?.workspaceId ?? \"n/a\"),\n    namespace,\n    fallback,\n  });\n\n  const onlyValidIds = useMemo(\n    () => kv.value?.filter((id) => subEnvironments.some((e) => e.id === id)) ?? [],\n    [kv.value, subEnvironments],\n  );\n\n  return onlyValidIds;\n}\n\nexport function useSubscribeRecentEnvironments() {\n  useEffect(() => {\n    return jotaiStore.sub(activeEnvironmentAtom, async () => {\n      const activeEnvironment = jotaiStore.get(activeEnvironmentAtom);\n      if (activeEnvironment == null) return;\n\n      const key = kvKey(activeEnvironment.workspaceId);\n      const recentIds = getKeyValue<string[]>({ namespace, key, fallback });\n      if (recentIds[0] === activeEnvironment.id) return; // Short-circuit\n\n      const withoutActiveId = recentIds.filter((id) => id !== activeEnvironment.id);\n      const value = [activeEnvironment.id, ...withoutActiveId];\n      await setKeyValue({ namespace, key, value });\n    });\n  }, []);\n}\n\nexport async function getRecentEnvironments(workspaceId: string) {\n  return getKeyValue<string[]>({\n    namespace,\n    key: kvKey(workspaceId),\n    fallback,\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useRecentRequests.ts",
    "content": "import { useEffect, useMemo } from \"react\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { getKeyValue, setKeyValue } from \"../lib/keyValueStore\";\nimport { activeRequestAtom } from \"./useActiveRequest\";\nimport { useAllRequests } from \"./useAllRequests\";\nimport { useKeyValue } from \"./useKeyValue\";\n\nconst kvKey = (workspaceId: string) => `recent_requests::${workspaceId}`;\nconst namespace = \"global\";\nconst fallback: string[] = [];\n\nexport function useRecentRequests() {\n  const requests = useAllRequests();\n\n  const { set: setRecentRequests, value: recentRequests } = useKeyValue<string[]>({\n    key: kvKey(requests[0]?.workspaceId ?? \"n/a\"),\n    namespace,\n    fallback,\n  });\n\n  const onlyValidIds = useMemo(\n    () => recentRequests?.filter((id) => requests.some((r) => r.id === id)) ?? [],\n    [recentRequests, requests],\n  );\n\n  return [onlyValidIds, setRecentRequests] as const;\n}\n\nexport function useSubscribeRecentRequests() {\n  useEffect(() => {\n    return jotaiStore.sub(activeRequestAtom, async () => {\n      const activeRequest = jotaiStore.get(activeRequestAtom);\n      if (activeRequest == null) return;\n\n      const key = kvKey(activeRequest.workspaceId);\n\n      const recentIds = getKeyValue<string[]>({ namespace, key, fallback });\n      if (recentIds[0] === activeRequest.id) return; // Short-circuit\n\n      const withoutActiveId = recentIds.filter((id) => id !== activeRequest.id);\n      const value = [activeRequest.id, ...withoutActiveId];\n      await setKeyValue({ namespace, key, value });\n    });\n  }, []);\n}\n\nexport async function getRecentRequests(workspaceId: string) {\n  return getKeyValue<string[]>({\n    namespace,\n    key: kvKey(workspaceId),\n    fallback,\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useRecentWorkspaces.ts",
    "content": "import { workspacesAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useEffect, useMemo } from \"react\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { getKeyValue, setKeyValue } from \"../lib/keyValueStore\";\nimport { activeWorkspaceIdAtom } from \"./useActiveWorkspace\";\nimport { useKeyValue } from \"./useKeyValue\";\n\nconst kvKey = () => \"recent_workspaces\";\nconst namespace = \"global\";\nconst fallback: string[] = [];\n\nexport function useRecentWorkspaces() {\n  const workspaces = useAtomValue(workspacesAtom);\n  const { value, isLoading } = useKeyValue<string[]>({ key: kvKey(), namespace, fallback });\n\n  const onlyValidIds = useMemo(\n    () => value?.filter((id) => workspaces.some((w) => w.id === id)) ?? [],\n    [value, workspaces],\n  );\n\n  if (isLoading) return null;\n\n  return onlyValidIds;\n}\n\nexport function useSubscribeRecentWorkspaces() {\n  useEffect(() => {\n    const unsub = jotaiStore.sub(activeWorkspaceIdAtom, updateRecentWorkspaces);\n    updateRecentWorkspaces().catch(console.error); // Update when opened in a new window\n    return unsub;\n  }, []);\n}\n\nasync function updateRecentWorkspaces() {\n  const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n  if (activeWorkspaceId == null) return;\n\n  const key = kvKey();\n\n  const recentIds = getKeyValue<string[]>({ namespace, key, fallback });\n  if (recentIds[0] === activeWorkspaceId) return; // Short-circuit\n\n  const withoutActiveId = recentIds.filter((id) => id !== activeWorkspaceId);\n  const value = [activeWorkspaceId, ...withoutActiveId];\n  console.log(\"Recent workspaces update\", activeWorkspaceId);\n  await setKeyValue({ namespace, key, value });\n}\n"
  },
  {
    "path": "src-web/hooks/useRenderTemplate.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type { RenderPurpose } from \"@yaakapp-internal/plugins\";\nimport { useAtomValue } from \"jotai\";\nimport { minPromiseMillis } from \"../lib/minPromiseMillis\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { useActiveEnvironment } from \"./useActiveEnvironment\";\nimport { activeWorkspaceIdAtom } from \"./useActiveWorkspace\";\n\nexport function useRenderTemplate({\n  template,\n  enabled,\n  purpose,\n  refreshKey,\n  ignoreError,\n  preservePreviousValue,\n}: {\n  template: string;\n  enabled: boolean;\n  purpose: RenderPurpose;\n  refreshKey?: string | null;\n  ignoreError?: boolean;\n  preservePreviousValue?: boolean;\n}) {\n  const workspaceId = useAtomValue(activeWorkspaceIdAtom) ?? \"n/a\";\n  const environmentId = useActiveEnvironment()?.id ?? null;\n  return useQuery<string>({\n    refetchOnWindowFocus: false,\n    enabled,\n    placeholderData: preservePreviousValue ? (prev) => prev : undefined,\n    queryKey: [\"render_template\", workspaceId, environmentId, refreshKey, purpose, ignoreError],\n    queryFn: () =>\n      minPromiseMillis(\n        renderTemplate({ template, workspaceId, environmentId, purpose, ignoreError }),\n        300,\n      ),\n  });\n}\n\nexport async function renderTemplate({\n  template,\n  workspaceId,\n  environmentId,\n  purpose,\n  ignoreError,\n}: {\n  template: string;\n  workspaceId: string;\n  environmentId: string | null;\n  purpose: RenderPurpose;\n  ignoreError?: boolean;\n}): Promise<string> {\n  return invokeCmd(\"cmd_render_template\", {\n    template,\n    workspaceId,\n    environmentId,\n    purpose,\n    ignoreError,\n  });\n}\n\nexport async function decryptTemplate({\n  template,\n  workspaceId,\n  environmentId,\n}: {\n  template: string;\n  workspaceId: string;\n  environmentId: string | null;\n}): Promise<string> {\n  return invokeCmd(\"cmd_decrypt_template\", { template, workspaceId, environmentId });\n}\n"
  },
  {
    "path": "src-web/hooks/useRequestEditor.tsx",
    "content": "import EventEmitter from \"eventemitter3\";\nimport { atom, useAtom } from \"jotai\";\nimport type { DependencyList } from \"react\";\nimport { useCallback, useEffect } from \"react\";\n\ntype EventDataMap = {\n  \"request_params.focus_value\": string;\n  \"request_pane.focus_tab\": undefined;\n};\n\nexport function useRequestEditorEvent<\n  Event extends keyof EventDataMap,\n  Data extends EventDataMap[Event],\n>(event: Event, fn: (data: Data) => void, deps?: DependencyList) {\n  useEffect(() => {\n    emitter.on(event, fn);\n    return () => {\n      emitter.off(event, fn);\n    };\n    // oxlint-disable-next-line react-hooks/exhaustive-deps -- We're handing deps manually\n  }, deps);\n}\n\nexport const urlKeyAtom = atom<string>(Math.random().toString());\nexport const urlParamsKeyAtom = atom<string>(Math.random().toString());\n\nexport function useRequestEditor() {\n  const [urlParametersKey, setUrlParametersKey] = useAtom(urlParamsKeyAtom);\n  const [urlKey, setUrlKey] = useAtom(urlKeyAtom);\n  const focusParamsTab = useCallback(() => {\n    emitter.emit(\"request_pane.focus_tab\", undefined);\n  }, []);\n\n  const focusParamValue = useCallback(\n    (name: string) => {\n      focusParamsTab();\n      requestAnimationFrame(() => emitter.emit(\"request_params.focus_value\", name));\n    },\n    [focusParamsTab],\n  );\n\n  const forceUrlRefresh = useCallback(() => setUrlKey(Math.random().toString()), [setUrlKey]);\n  const forceParamsRefresh = useCallback(\n    () => setUrlParametersKey(Math.random().toString()),\n    [setUrlParametersKey],\n  );\n\n  return [\n    {\n      urlParametersKey,\n      urlKey,\n    },\n    {\n      focusParamValue,\n      focusParamsTab,\n      forceParamsRefresh,\n      forceUrlRefresh,\n    },\n  ] as const;\n}\n\nconst emitter = new (class RequestEditorEventEmitter {\n  #emitter: EventEmitter = new EventEmitter();\n\n  emit<Event extends keyof EventDataMap, Data extends EventDataMap[Event]>(\n    event: Event,\n    data: Data,\n  ) {\n    this.#emitter.emit(event, data);\n  }\n\n  on<Event extends keyof EventDataMap, Data extends EventDataMap[Event]>(\n    event: Event,\n    fn: (data: Data) => void,\n  ) {\n    this.#emitter.on(event, fn);\n  }\n\n  off<Event extends keyof EventDataMap, Data extends EventDataMap[Event]>(\n    event: Event,\n    fn: (data: Data) => void,\n  ) {\n    this.#emitter.off(event, fn);\n  }\n})();\n"
  },
  {
    "path": "src-web/hooks/useRequestUpdateKey.ts",
    "content": "import { getCurrentWebviewWindow } from \"@tauri-apps/api/webviewWindow\";\nimport type { ModelPayload } from \"@yaakapp-internal/models\";\nimport { atom, useAtomValue } from \"jotai\";\nimport { generateId } from \"../lib/generateId\";\nimport { jotaiStore } from \"../lib/jotai\";\n\nconst requestUpdateKeyAtom = atom<Record<string, string>>({});\n\ngetCurrentWebviewWindow()\n  .listen<ModelPayload>(\"model_write\", ({ payload }) => {\n    if (payload.change.type !== \"upsert\") return;\n\n    if (\n      (payload.model.model === \"http_request\" ||\n        payload.model.model === \"grpc_request\" ||\n        payload.model.model === \"websocket_request\") &&\n      ((payload.updateSource.type === \"window\" &&\n        payload.updateSource.label !== getCurrentWebviewWindow().label) ||\n        payload.updateSource.type !== \"window\")\n    ) {\n      wasUpdatedExternally(payload.model.id);\n    }\n  })\n  .catch(console.error);\n\nexport function wasUpdatedExternally(changedRequestId: string) {\n  jotaiStore.set(requestUpdateKeyAtom, (m) => ({ ...m, [changedRequestId]: generateId() }));\n}\n\nexport function useRequestUpdateKey(requestId: string | null) {\n  const keys = useAtomValue(requestUpdateKeyAtom);\n  const key = keys[requestId ?? \"n/a\"];\n  return `${requestId}::${key ?? \"default\"}`;\n}\n"
  },
  {
    "path": "src-web/hooks/useResolvedAppearance.ts",
    "content": "import { settingsAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { resolveAppearance } from \"../lib/theme/appearance\";\nimport { usePreferredAppearance } from \"./usePreferredAppearance\";\n\nexport function useResolvedAppearance() {\n  const preferredAppearance = usePreferredAppearance();\n  const settings = useAtomValue(settingsAtom);\n  return resolveAppearance(preferredAppearance, settings.appearance);\n}\n"
  },
  {
    "path": "src-web/hooks/useResolvedTheme.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport { settingsAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { getResolvedTheme, getThemes } from \"../lib/theme/themes\";\nimport { usePluginsKey } from \"./usePlugins\";\nimport { usePreferredAppearance } from \"./usePreferredAppearance\";\n\nexport function useResolvedTheme() {\n  const preferredAppearance = usePreferredAppearance();\n  const settings = useAtomValue(settingsAtom);\n  const pluginKey = usePluginsKey();\n  return useQuery({\n    placeholderData: (prev) => prev,\n    queryKey: [\"resolved_theme\", preferredAppearance, settings.updatedAt, pluginKey],\n    queryFn: async () => {\n      const data = await getResolvedTheme(\n        preferredAppearance,\n        settings.appearance,\n        settings.themeLight,\n        settings.themeDark,\n      );\n      return { ...data, ...(await getThemes()) };\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useResponseBodyEventSource.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type { HttpResponse } from \"@yaakapp-internal/models\";\nimport type { ServerSentEvent } from \"@yaakapp-internal/sse\";\nimport { getResponseBodyEventSource } from \"../lib/responseBody\";\n\nexport function useResponseBodyEventSource(response: HttpResponse) {\n  return useQuery<ServerSentEvent[]>({\n    placeholderData: (prev) => prev, // Keep previous data on refetch\n    queryKey: [\"response-body-event-source\", response.id, response.contentLength],\n    queryFn: () => getResponseBodyEventSource(response),\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useResponseBodyText.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { getResponseBodyBytes, getResponseBodyText } from \"../lib/responseBody\";\n\nexport function useResponseBodyText({\n  response,\n  filter,\n}: {\n  response: HttpResponse;\n  filter: string | null;\n}) {\n  return useQuery({\n    placeholderData: (prev) => prev, // Keep previous data on refetch\n    queryKey: [\n      \"response_body_text\",\n      response.id,\n      response.updatedAt,\n      response.contentLength,\n      filter ?? \"\",\n    ],\n    queryFn: () => getResponseBodyText({ response, filter }),\n  });\n}\n\nexport function useResponseBodyBytes({ response }: { response: HttpResponse }) {\n  return useQuery({\n    placeholderData: (prev) => prev, // Keep previous data on refetch\n    queryKey: [\"response_body_bytes\", response.id, response.updatedAt, response.contentLength],\n    queryFn: () => getResponseBodyBytes(response),\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useResponseViewMode.ts",
    "content": "import { useLocalStorage } from \"react-use\";\n\nconst DEFAULT_VIEW_MODE = \"pretty\";\n\nexport function useResponseViewMode(requestId?: string): [string, (m: \"pretty\" | \"raw\") => void] {\n  const [value, setValue] = useLocalStorage<\"pretty\" | \"raw\">(`response_view_mode::${requestId}`);\n  return [value ?? DEFAULT_VIEW_MODE, setValue];\n}\n"
  },
  {
    "path": "src-web/hooks/useSaveResponse.tsx",
    "content": "import { save } from \"@tauri-apps/plugin-dialog\";\nimport type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { getModel } from \"@yaakapp-internal/models\";\nimport mime from \"mime\";\nimport slugify from \"slugify\";\nimport { InlineCode } from \"../components/core/InlineCode\";\nimport { getContentTypeFromHeaders } from \"../lib/model_util\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { showToast } from \"../lib/toast\";\nimport { useFastMutation } from \"./useFastMutation\";\n\nexport function useSaveResponse(response: HttpResponse) {\n  return useFastMutation({\n    mutationKey: [\"save_response\", response.id],\n    mutationFn: async () => {\n      const request = getModel(\"http_request\", response.requestId);\n      if (request == null) return null;\n\n      const contentType = getContentTypeFromHeaders(response.headers) ?? \"unknown\";\n      const ext = mime.getExtension(contentType);\n      const slug = slugify(request.name || \"response\", { lower: true });\n      const filepath = await save({\n        defaultPath: ext ? `${slug}.${ext}` : slug,\n        title: \"Save Response\",\n      });\n      await invokeCmd(\"cmd_save_response\", { responseId: response.id, filepath });\n      showToast({\n        message: (\n          <>\n            Response saved to <InlineCode>{filepath}</InlineCode>\n          </>\n        ),\n      });\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useScrollIntoView.ts",
    "content": "import { useEffect } from \"react\";\n\nexport function useScrollIntoView<T extends HTMLElement>(node: T | null, enabled: boolean) {\n  useEffect(() => {\n    if (enabled) {\n      node?.scrollIntoView({ block: \"nearest\" });\n    }\n  }, [enabled, node]);\n}\n"
  },
  {
    "path": "src-web/hooks/useSendAnyHttpRequest.ts",
    "content": "import type { HttpResponse } from \"@yaakapp-internal/models\";\nimport { getModel } from \"@yaakapp-internal/models\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { getActiveCookieJar } from \"./useActiveCookieJar\";\nimport { getActiveEnvironment } from \"./useActiveEnvironment\";\nimport { createFastMutation, useFastMutation } from \"./useFastMutation\";\n\nexport function useSendAnyHttpRequest() {\n  return useFastMutation<HttpResponse | null, string, string | null>({\n    mutationKey: [\"send_any_request\"],\n    mutationFn: async (id) => {\n      const request = getModel(\"http_request\", id ?? \"n/a\");\n      if (request == null) {\n        return null;\n      }\n\n      return invokeCmd(\"cmd_send_http_request\", {\n        request,\n        environmentId: getActiveEnvironment()?.id,\n        cookieJarId: getActiveCookieJar()?.id,\n      });\n    },\n  });\n}\n\nexport const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({\n  mutationKey: [\"send_any_request\"],\n  mutationFn: async (id) => {\n    const request = getModel(\"http_request\", id ?? \"n/a\");\n    if (request == null) {\n      return null;\n    }\n\n    return invokeCmd(\"cmd_send_http_request\", {\n      request,\n      environmentId: getActiveEnvironment()?.id,\n      cookieJarId: getActiveCookieJar()?.id,\n    });\n  },\n});\n"
  },
  {
    "path": "src-web/hooks/useSendManyRequests.ts",
    "content": "import { useFastMutation } from \"./useFastMutation\";\nimport { useSendAnyHttpRequest } from \"./useSendAnyHttpRequest\";\n\nexport function useSendManyRequests() {\n  const sendAnyRequest = useSendAnyHttpRequest();\n  return useFastMutation<void, string, string[]>({\n    mutationKey: [\"send_many_requests\"],\n    mutationFn: async (requestIds: string[]) => {\n      for (const id of requestIds) {\n        sendAnyRequest.mutate(id);\n      }\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useShouldFloatSidebar.ts",
    "content": "import { useWindowSize } from \"react-use\";\n\nconst WINDOW_FLOATING_SIDEBAR_WIDTH = 600;\n\nexport function useShouldFloatSidebar() {\n  const windowSize = useWindowSize();\n  return windowSize.width <= WINDOW_FLOATING_SIDEBAR_WIDTH;\n}\n"
  },
  {
    "path": "src-web/hooks/useSidebarHidden.ts",
    "content": "import { useAtomValue } from \"jotai\";\nimport { activeWorkspaceIdAtom } from \"./useActiveWorkspace\";\nimport { useKeyValue } from \"./useKeyValue\";\n\nexport function useSidebarHidden() {\n  const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom);\n  const { set, value } = useKeyValue<boolean>({\n    namespace: \"no_sync\",\n    key: [\"sidebar_hidden\", activeWorkspaceId ?? \"n/a\"],\n    fallback: false,\n  });\n\n  return [value, set] as const;\n}\n"
  },
  {
    "path": "src-web/hooks/useSidebarItemCollapsed.ts",
    "content": "import { atom } from \"jotai\";\nimport { atomWithKVStorage } from \"../lib/atoms/atomWithKVStorage\";\nimport { activeWorkspaceIdAtom } from \"./useActiveWorkspace\";\n\nfunction kvKey(workspaceId: string | null) {\n  return [\"sidebar_collapsed\", workspaceId ?? \"n/a\"];\n}\n\nexport const sidebarCollapsedAtom = atom((get) => {\n  const workspaceId = get(activeWorkspaceIdAtom);\n  return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {});\n});\n"
  },
  {
    "path": "src-web/hooks/useSidebarWidth.ts",
    "content": "import { useAtomValue } from \"jotai\";\nimport { useCallback } from \"react\";\nimport { useLocalStorage } from \"react-use\";\nimport { activeWorkspaceIdAtom } from \"./useActiveWorkspace\";\n\nexport function useSidebarWidth() {\n  const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom);\n  const [width, setWidth] = useLocalStorage<number>(\n    `sidebar_width::${activeWorkspaceId ?? \"n/a\"}`,\n    250,\n  );\n  const resetWidth = useCallback(() => setWidth(250), [setWidth]);\n  return [width ?? null, setWidth, resetWidth] as const;\n}\n"
  },
  {
    "path": "src-web/hooks/useStateWithDeps.ts",
    "content": "import type { DependencyList } from \"react\";\nimport { useEffect, useState } from \"react\";\n\n/**\n * Like useState, except it will update the value when the default value changes\n */\nexport function useStateWithDeps<T>(defaultValue: T | (() => T), deps: DependencyList) {\n  const [value, setValue] = useState(defaultValue);\n  // oxlint-disable-next-line react-hooks/exhaustive-deps\n  useEffect(() => {\n    setValue(defaultValue);\n  }, [...deps]);\n  return [value, setValue] as const;\n}\n"
  },
  {
    "path": "src-web/hooks/useStoplightsVisible.ts",
    "content": "import { type } from \"@tauri-apps/plugin-os\";\nimport { useIsFullscreen } from \"./useIsFullscreen\";\n\nexport function useStoplightsVisible() {\n  const fullscreen = useIsFullscreen();\n  const stoplightsVisible = type() === \"macos\" && !fullscreen;\n  return stoplightsVisible;\n}\n"
  },
  {
    "path": "src-web/hooks/useSyncFontSizeSetting.ts",
    "content": "import { getCurrentWebviewWindow } from \"@tauri-apps/api/webviewWindow\";\nimport { settingsAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useEffect } from \"react\";\n\nexport function useSyncFontSizeSetting() {\n  const settings = useAtomValue(settingsAtom);\n  useEffect(() => {\n    if (settings == null) {\n      return;\n    }\n\n    const { interfaceScale, editorFontSize } = settings;\n    getCurrentWebviewWindow().setZoom(interfaceScale).catch(console.error);\n    document.documentElement.style.setProperty(\"--editor-font-size\", `${editorFontSize}px`);\n  }, [settings]);\n}\n"
  },
  {
    "path": "src-web/hooks/useSyncWorkspaceChildModels.ts",
    "content": "import { changeModelStoreWorkspace } from \"@yaakapp-internal/models\";\nimport { useEffect } from \"react\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { activeWorkspaceIdAtom } from \"./useActiveWorkspace\";\n\nexport function useSyncWorkspaceChildModels() {\n  useEffect(() => {\n    const unsub = jotaiStore.sub(activeWorkspaceIdAtom, sync);\n    sync().catch(console.error);\n    return unsub;\n  }, []);\n}\n\nasync function sync() {\n  const workspaceId = jotaiStore.get(activeWorkspaceIdAtom) ?? null;\n  changeModelStoreWorkspace(workspaceId).catch(console.error);\n}\n"
  },
  {
    "path": "src-web/hooks/useSyncWorkspaceRequestTitle.ts",
    "content": "import { setWindowTitle } from \"@yaakapp-internal/mac-window\";\nimport { settingsAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useEffect } from \"react\";\nimport { appInfo } from \"../lib/appInfo\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { resolvedModelName } from \"../lib/resolvedModelName\";\nimport { useActiveEnvironment } from \"./useActiveEnvironment\";\nimport { activeRequestAtom } from \"./useActiveRequest\";\nimport { activeWorkspaceAtom } from \"./useActiveWorkspace\";\n\nexport function useSyncWorkspaceRequestTitle() {\n  const activeWorkspace = useAtomValue(activeWorkspaceAtom);\n  const activeEnvironment = useActiveEnvironment();\n  const activeRequest = useAtomValue(activeRequestAtom);\n\n  useEffect(() => {\n    const settings = jotaiStore.get(settingsAtom);\n    let newTitle = activeWorkspace ? activeWorkspace.name : \"Yaak\";\n    if (activeEnvironment) {\n      newTitle += ` (${activeEnvironment.name})`;\n    }\n\n    if (!settings.useNativeTitlebar && activeRequest) {\n      newTitle += ` › ${resolvedModelName(activeRequest)}`;\n    }\n\n    if (appInfo.isDev) {\n      newTitle = `[DEV] ${newTitle}`;\n    }\n\n    setWindowTitle(newTitle);\n  }, [activeEnvironment, activeRequest, activeWorkspace]);\n}\n"
  },
  {
    "path": "src-web/hooks/useSyncZoomSetting.ts",
    "content": "import { useHotKey } from \"./useHotKey\";\nimport { useListenToTauriEvent } from \"./useListenToTauriEvent\";\nimport { useZoom } from \"./useZoom\";\n\nexport function useSyncZoomSetting() {\n  // Handle Zoom.\n  // Note, Mac handles it in the app menu, so need to also handle keyboard\n  // shortcuts for Windows/Linux\n  const zoom = useZoom();\n  useHotKey(\"app.zoom_in\", zoom.zoomIn);\n  useListenToTauriEvent(\"zoom_in\", zoom.zoomIn);\n  useHotKey(\"app.zoom_out\", zoom.zoomOut);\n  useListenToTauriEvent(\"zoom_out\", zoom.zoomOut);\n  useHotKey(\"app.zoom_reset\", zoom.zoomReset);\n  useListenToTauriEvent(\"zoom_reset\", zoom.zoomReset);\n}\n"
  },
  {
    "path": "src-web/hooks/useTemplateFunctionConfig.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport {\n  environmentsAtom,\n  type Folder,\n  type GrpcRequest,\n  type HttpRequest,\n  httpResponsesAtom,\n  pluginsAtom,\n  type WebsocketRequest,\n  type Workspace,\n} from \"@yaakapp-internal/models\";\nimport type { GetTemplateFunctionConfigResponse, JsonPrimitive } from \"@yaakapp-internal/plugins\";\nimport { useAtomValue } from \"jotai\";\nimport { md5 } from \"js-md5\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { activeEnvironmentIdAtom } from \"./useActiveEnvironment\";\nimport { activeWorkspaceIdAtom } from \"./useActiveWorkspace\";\n\nexport function useTemplateFunctionConfig(\n  functionName: string | null,\n  values: Record<string, JsonPrimitive>,\n  model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,\n) {\n  const pluginsKey = useAtomValue(pluginsAtom);\n  const workspaceId = useAtomValue(activeWorkspaceIdAtom);\n  const environmentId = useAtomValue(activeEnvironmentIdAtom);\n  const responses = useAtomValue(httpResponsesAtom);\n  const environments = useAtomValue(environmentsAtom);\n  const environmentsKey = environments.map((e) => e.id + e.updatedAt).join(\":\");\n\n  // Some auth handlers like OAuth 2.0 show the current token after a successful request. To\n  // handle that, we'll force the auth to re-fetch after each new response closes\n  const responseKey = md5(\n    responses\n      .filter((r) => r.state === \"closed\")\n      .map((r) => r.id)\n      .join(\":\"),\n  );\n\n  return useQuery({\n    queryKey: [\n      \"template_function_config\",\n      model,\n      functionName,\n      values,\n      workspaceId, // Refresh when the active workspace changes\n      environmentId, // Refresh when the active environment changes\n      environmentsKey, // Refresh when environments change\n      responseKey, // Refresh when responses change\n      pluginsKey, // Refresh when plugins reload\n    ],\n    placeholderData: (prev) => prev, // Keep previous data on refetch\n    queryFn: async () => {\n      if (functionName == null) return null;\n      return getTemplateFunctionConfig(functionName, values, model, environmentId);\n    },\n  });\n}\n\nexport async function getTemplateFunctionConfig(\n  functionName: string,\n  values: Record<string, JsonPrimitive>,\n  model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,\n  environmentId: string | undefined,\n) {\n  const config = await invokeCmd<GetTemplateFunctionConfigResponse>(\n    \"cmd_template_function_config\",\n    {\n      functionName,\n      values,\n      model,\n      environmentId,\n    },\n  );\n  return config.function;\n}\n"
  },
  {
    "path": "src-web/hooks/useTemplateFunctions.tsx",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type {\n  GetTemplateFunctionSummaryResponse,\n  TemplateFunction,\n} from \"@yaakapp-internal/plugins\";\nimport { atom, useAtomValue, useSetAtom } from \"jotai\";\nimport { useMemo, useState } from \"react\";\nimport type { TwigCompletionOption } from \"../components/core/Editor/twig/completion\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { usePluginsKey } from \"./usePlugins\";\n\nconst templateFunctionsAtom = atom<TemplateFunction[]>([]);\n\nexport function useTemplateFunctionCompletionOptions(\n  onClick: (fn: TemplateFunction, ragTag: string, pos: number) => void,\n  enabled: boolean,\n) {\n  const templateFunctions = useAtomValue(templateFunctionsAtom);\n  return useMemo<TwigCompletionOption[]>(() => {\n    if (!enabled) {\n      return [];\n    }\n    return templateFunctions.map((fn) => {\n      const argsLabel = fn.args.length > 0 ? \"…\" : \"\";\n      const fn2: TwigCompletionOption = {\n        type: \"function\",\n        onClick: (rawTag: string, startPos: number) => onClick(fn, rawTag, startPos),\n        label: `${fn.name}(${argsLabel})`,\n        invalid: false,\n        value: null,\n        ...fn,\n      };\n      return fn2;\n    });\n  }, [enabled, onClick, templateFunctions]);\n}\n\nexport function useSubscribeTemplateFunctions() {\n  const pluginsKey = usePluginsKey();\n  const [numFns, setNumFns] = useState<number>(0);\n  const setAtom = useSetAtom(templateFunctionsAtom);\n\n  useQuery({\n    queryKey: [\"template_functions\", pluginsKey],\n    // Fetch periodically until functions are returned\n    // NOTE: visibilitychange (refetchOnWindowFocus) does not work on Windows, so we'll rely on this logic\n    //  to refetch things until that's working again\n    // TODO: Update plugin system to wait for plugins to initialize before sending the first event to them\n    refetchInterval: numFns > 0 ? Number.POSITIVE_INFINITY : 1000,\n    refetchOnMount: true,\n    queryFn: async () => {\n      const result = await invokeCmd<GetTemplateFunctionSummaryResponse[]>(\n        \"cmd_template_function_summaries\",\n      );\n      setNumFns(result.length);\n      const functions = result.flatMap((r) => r.functions) ?? [];\n      setAtom(functions);\n      return functions;\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/hooks/useTemplateTokensToString.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type { Tokens } from \"@yaakapp-internal/templates\";\nimport { invokeCmd } from \"../lib/tauri\";\n\nexport function useTemplateTokensToString(tokens: Tokens) {\n  return useQuery<string>({\n    refetchOnWindowFocus: false,\n    queryKey: [\"template_tokens_to_string\", tokens],\n    queryFn: () => templateTokensToString(tokens),\n  });\n}\n\nexport async function templateTokensToString(tokens: Tokens): Promise<string> {\n  return invokeCmd(\"cmd_template_tokens_to_string\", { tokens });\n}\n"
  },
  {
    "path": "src-web/hooks/useTimedBoolean.ts",
    "content": "import { useRef, useState } from \"react\";\nimport { useUnmount } from \"react-use\";\n\n/** Returns a boolean that is true for a given number of milliseconds. */\nexport function useTimedBoolean(millis = 1500): [boolean, () => void] {\n  const [value, setValue] = useState(false);\n  const timeout = useRef<NodeJS.Timeout | null>(null);\n  const reset = () => timeout.current && clearTimeout(timeout.current);\n\n  useUnmount(reset);\n\n  const setToTrue = () => {\n    setValue(true);\n    reset();\n    timeout.current = setTimeout(() => setValue(false), millis);\n  };\n\n  return [value, setToTrue];\n}\n"
  },
  {
    "path": "src-web/hooks/useTimelineViewMode.ts",
    "content": "import type { TimelineViewMode } from \"../components/HttpResponsePane\";\nimport { useKeyValue } from \"./useKeyValue\";\n\nconst DEFAULT_VIEW_MODE: TimelineViewMode = \"timeline\";\n\nexport function useTimelineViewMode() {\n  const { set, value } = useKeyValue<TimelineViewMode>({\n    namespace: \"no_sync\",\n    key: \"timeline_view_mode\",\n    fallback: DEFAULT_VIEW_MODE,\n  });\n\n  return [value ?? DEFAULT_VIEW_MODE, set] as const;\n}\n"
  },
  {
    "path": "src-web/hooks/useToggle.ts",
    "content": "import { useCallback, useState } from \"react\";\n\nexport function useToggle(initialValue = false) {\n  const [value, setValue] = useState<boolean>(initialValue);\n  const toggle = useCallback(() => setValue((v) => !v), []);\n  return [value, toggle, setValue] as const;\n}\n"
  },
  {
    "path": "src-web/hooks/useToggleCommandPalette.tsx",
    "content": "import { useCallback } from \"react\";\nimport { CommandPaletteDialog } from \"../components/CommandPaletteDialog\";\nimport { toggleDialog } from \"../lib/dialog\";\n\nexport function useToggleCommandPalette() {\n  const togglePalette = useCallback(() => {\n    toggleDialog({\n      id: \"command_palette\",\n      size: \"dynamic\",\n      hideX: true,\n      className: \"mb-auto mt-[4rem] !max-h-[min(30rem,calc(100vh-4rem))]\",\n      vAlign: \"top\",\n      noPadding: true,\n      noScroll: true,\n      render: ({ hide }) => <CommandPaletteDialog onClose={hide} />,\n    });\n  }, []);\n\n  return togglePalette;\n}\n"
  },
  {
    "path": "src-web/hooks/useWebsocketRequestActions.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type { WebsocketRequest } from \"@yaakapp-internal/models\";\nimport type {\n  CallWebsocketRequestActionRequest,\n  GetWebsocketRequestActionsResponse,\n  WebsocketRequestAction,\n} from \"@yaakapp-internal/plugins\";\nimport { useMemo } from \"react\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { usePluginsKey } from \"./usePlugins\";\n\nexport type CallableWebSocketRequestAction = Pick<WebsocketRequestAction, \"label\" | \"icon\"> & {\n  call: (request: WebsocketRequest) => Promise<void>;\n};\n\nexport function useWebsocketRequestActions() {\n  const pluginsKey = usePluginsKey();\n\n  const actionsResult = useQuery<CallableWebSocketRequestAction[]>({\n    queryKey: [\"websocket_request_actions\", pluginsKey],\n    queryFn: () => getWebsocketRequestActions(),\n  });\n\n  // oxlint-disable-next-line react-hooks/exhaustive-deps\n  const actions = useMemo(() => {\n    return actionsResult.data ?? [];\n  }, [JSON.stringify(actionsResult.data)]);\n\n  return actions;\n}\n\nexport async function getWebsocketRequestActions() {\n  const responses = await invokeCmd<GetWebsocketRequestActionsResponse[]>(\n    \"cmd_websocket_request_actions\",\n  );\n  const actions = responses.flatMap((r) =>\n    r.actions.map((a: WebsocketRequestAction, i: number) => ({\n      label: a.label,\n      icon: a.icon,\n      call: async (websocketRequest: WebsocketRequest) => {\n        const payload: CallWebsocketRequestActionRequest = {\n          index: i,\n          pluginRefId: r.pluginRefId,\n          args: { websocketRequest },\n        };\n        await invokeCmd(\"cmd_call_websocket_request_action\", { req: payload });\n      },\n    })),\n  );\n\n  return actions;\n}\n"
  },
  {
    "path": "src-web/hooks/useWindowFocus.ts",
    "content": "import { getCurrentWebviewWindow } from \"@tauri-apps/api/webviewWindow\";\nimport { useEffect, useState } from \"react\";\nimport { fireAndForget } from \"../lib/fireAndForget\";\n\nexport function useWindowFocus() {\n  const [visible, setVisible] = useState(true);\n\n  useEffect(() => {\n    const unlisten = getCurrentWebviewWindow().onFocusChanged((e) => {\n      setVisible(e.payload);\n    });\n\n    return () => {\n      fireAndForget(unlisten.then((fn) => fn()));\n    };\n  }, []);\n\n  return visible;\n}\n"
  },
  {
    "path": "src-web/hooks/useWorkspaceActions.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport type { Workspace } from \"@yaakapp-internal/models\";\nimport type {\n  CallWorkspaceActionRequest,\n  GetWorkspaceActionsResponse,\n  WorkspaceAction,\n} from \"@yaakapp-internal/plugins\";\nimport { useMemo } from \"react\";\nimport { invokeCmd } from \"../lib/tauri\";\nimport { usePluginsKey } from \"./usePlugins\";\n\nexport type CallableWorkspaceAction = Pick<WorkspaceAction, \"label\" | \"icon\"> & {\n  call: (workspace: Workspace) => Promise<void>;\n};\n\nexport function useWorkspaceActions() {\n  const pluginsKey = usePluginsKey();\n\n  const actionsResult = useQuery<CallableWorkspaceAction[]>({\n    queryKey: [\"workspace_actions\", pluginsKey],\n    queryFn: () => getWorkspaceActions(),\n  });\n\n  // oxlint-disable-next-line react-hooks/exhaustive-deps\n  const actions = useMemo(() => {\n    return actionsResult.data ?? [];\n  }, [JSON.stringify(actionsResult.data)]);\n\n  return actions;\n}\n\nexport async function getWorkspaceActions() {\n  const responses = await invokeCmd<GetWorkspaceActionsResponse[]>(\"cmd_workspace_actions\");\n  const actions = responses.flatMap((r) =>\n    r.actions.map((a, i) => ({\n      label: a.label,\n      icon: a.icon,\n      call: async (workspace: Workspace) => {\n        const payload: CallWorkspaceActionRequest = {\n          index: i,\n          pluginRefId: r.pluginRefId,\n          args: { workspace },\n        };\n        await invokeCmd(\"cmd_call_workspace_action\", { req: payload });\n      },\n    })),\n  );\n\n  return actions;\n}\n"
  },
  {
    "path": "src-web/hooks/useZoom.ts",
    "content": "import { patchModel, settingsAtom } from \"@yaakapp-internal/models\";\nimport { useAtomValue } from \"jotai\";\nimport { useCallback } from \"react\";\n\nexport function useZoom() {\n  const settings = useAtomValue(settingsAtom);\n\n  const zoomIn = useCallback(async () => {\n    if (!settings) return;\n    await patchModel(settings, {\n      interfaceScale: Math.min(1.8, settings.interfaceScale * 1.1),\n    });\n  }, [settings]);\n\n  const zoomOut = useCallback(async () => {\n    if (!settings) return;\n    await patchModel(settings, {\n      interfaceScale: Math.max(0.4, settings.interfaceScale * 0.9),\n    });\n  }, [settings]);\n\n  const zoomReset = useCallback(async () => {\n    await patchModel(settings, { interfaceScale: 1 });\n  }, [settings]);\n\n  return { zoomIn, zoomOut, zoomReset };\n}\n"
  },
  {
    "path": "src-web/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Yaak App</title>\n    <!--  <script src=\"http://localhost:8097\"></script>-->\n\n    <!-- Certain elements like webview (and maybe <select>?) will use background\n  color depending on document background color-->\n    <style>\n      html,\n      body {\n        background-color: white;\n      }\n\n      @media (prefers-color-scheme: dark) {\n        html,\n        body {\n          background-color: #1b1a29;\n        }\n      }\n    </style>\n  </head>\n\n  <body class=\"text-base\">\n    <div id=\"root\"></div>\n    <div id=\"cm-portal\" class=\"cm-portal\"></div>\n    <div id=\"react-portal\"></div>\n    <div id=\"radix-portal\" class=\"cm-portal\"></div>\n    <script type=\"module\" src=\"/theme.ts\"></script>\n    <script type=\"module\" src=\"/font-size.ts\"></script>\n    <script type=\"module\" src=\"/font.ts\"></script>\n    <script type=\"module\" src=\"/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "src-web/init/sync.ts",
    "content": "import { debounce } from \"@yaakapp-internal/lib\";\nimport type { AnyModel, ModelPayload } from \"@yaakapp-internal/models\";\nimport { watchWorkspaceFiles } from \"@yaakapp-internal/sync\";\nimport { syncWorkspace } from \"../commands/commands\";\nimport { activeWorkspaceIdAtom, activeWorkspaceMetaAtom } from \"../hooks/useActiveWorkspace\";\nimport { listenToTauriEvent } from \"../hooks/useListenToTauriEvent\";\nimport { jotaiStore } from \"../lib/jotai\";\n\nexport function initSync() {\n  initModelListeners();\n  initFileChangeListeners();\n  sync().catch(console.error);\n}\n\nexport async function sync({ force }: { force?: boolean } = {}) {\n  const workspaceMeta = jotaiStore.get(activeWorkspaceMetaAtom);\n  if (workspaceMeta == null || workspaceMeta.settingSyncDir == null) {\n    return;\n  }\n\n  await syncWorkspace.mutateAsync({\n    workspaceId: workspaceMeta.workspaceId,\n    syncDir: workspaceMeta.settingSyncDir,\n    force,\n  });\n}\n\nconst debouncedSync = debounce(async () => {\n  await sync();\n}, 1000);\n\n/**\n * Subscribe to model change events. Since we check the workspace ID on sync, we can\n * simply add long-lived subscribers for the lifetime of the app.\n */\nfunction initModelListeners() {\n  listenToTauriEvent<ModelPayload>(\"model_write\", (p) => {\n    if (isModelRelevant(p.payload.model)) debouncedSync();\n  });\n}\n\n/**\n * Subscribe to relevant files for a workspace. Since the workspace can change, this will\n * keep track of the active workspace, as well as changes to the sync directory of the\n * current workspace, and re-subscribe when necessary.\n */\nfunction initFileChangeListeners() {\n  let unsub: null | ReturnType<typeof watchWorkspaceFiles> = null;\n  jotaiStore.sub(activeWorkspaceMetaAtom, async () => {\n    await unsub?.(); // Unsub to previous\n    const workspaceMeta = jotaiStore.get(activeWorkspaceMetaAtom);\n    if (workspaceMeta == null || workspaceMeta.settingSyncDir == null) return;\n    debouncedSync(); // Perform an initial sync when switching workspace\n    unsub = watchWorkspaceFiles(\n      workspaceMeta.workspaceId,\n      workspaceMeta.settingSyncDir,\n      debouncedSync,\n    );\n  });\n}\n\nfunction isModelRelevant(m: AnyModel) {\n  const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n\n  if (\n    m.model !== \"workspace\" &&\n    m.model !== \"folder\" &&\n    m.model !== \"environment\" &&\n    m.model !== \"http_request\" &&\n    m.model !== \"grpc_request\" &&\n    m.model !== \"websocket_request\"\n  ) {\n    return false;\n  }\n  if (m.model === \"workspace\") {\n    return m.id === workspaceId;\n  }\n  return m.workspaceId === workspaceId;\n}\n"
  },
  {
    "path": "src-web/lib/alert.ts",
    "content": "import type { AlertProps } from \"../components/core/Alert\";\nimport { Alert } from \"../components/core/Alert\";\nimport type { DialogProps } from \"../components/core/Dialog\";\nimport { showDialog } from \"./dialog\";\n\ninterface AlertArgs {\n  id: string;\n  title: DialogProps[\"title\"];\n  body: AlertProps[\"body\"];\n  size?: DialogProps[\"size\"];\n}\n\nexport function showAlert({ id, title, body, size = \"sm\" }: AlertArgs) {\n  showDialog({\n    id,\n    title,\n    hideX: true,\n    size,\n    disableBackdropClose: true, // Prevent accidental dismisses\n    render: ({ hide }) => Alert({ onHide: hide, body }),\n  });\n}\n\nexport function showSimpleAlert(title: string, message: string) {\n  showAlert({\n    id: \"simple-alert\",\n    body: message,\n    title: title,\n  });\n}\n"
  },
  {
    "path": "src-web/lib/appInfo.ts",
    "content": "import { getIdentifier } from \"@tauri-apps/api/app\";\nimport { invokeCmd } from \"./tauri\";\n\nexport interface AppInfo {\n  isDev: boolean;\n  version: string;\n  cliVersion: string | null;\n  name: string;\n  appDataDir: string;\n  appLogDir: string;\n  vendoredPluginDir: string;\n  defaultProjectDir: string;\n  identifier: string;\n  featureLicense: boolean;\n  featureUpdater: boolean;\n}\n\nexport const appInfo = {\n  ...(await invokeCmd(\"cmd_metadata\")),\n  identifier: await getIdentifier(),\n} as AppInfo;\n\nconsole.log(\"App info\", appInfo);\n"
  },
  {
    "path": "src-web/lib/atoms/atomWithKVStorage.ts",
    "content": "import { atom } from \"jotai\";\nimport { getKeyValue, setKeyValue } from \"../keyValueStore\";\n\nexport function atomWithKVStorage<T extends object | boolean | number | string | null>(\n  key: string | string[],\n  fallback: T,\n  namespace = \"global\",\n) {\n  const baseAtom = atom<T>(fallback);\n\n  baseAtom.onMount = (setValue) => {\n    setValue(getKeyValue<T>({ namespace, key, fallback }));\n  };\n\n  const derivedAtom = atom<T, [T | ((prev: T) => T)], void>(\n    (get) => get(baseAtom),\n    (get, set, update) => {\n      const nextValue = typeof update === \"function\" ? update(get(baseAtom)) : update;\n      set(baseAtom, nextValue);\n      setKeyValue({ namespace, key, value: nextValue }).catch(console.error);\n    },\n  );\n\n  return derivedAtom;\n}\n"
  },
  {
    "path": "src-web/lib/atoms.ts",
    "content": "import deepEqual from \"@gilbarbara/deep-equal\";\nimport type { UpdateInfo } from \"@yaakapp-internal/tauri\";\nimport type { Atom } from \"jotai\";\nimport { atom } from \"jotai\";\nimport { selectAtom } from \"jotai/utils\";\nimport type { SplitLayoutLayout } from \"../components/core/SplitLayout\";\nimport { atomWithKVStorage } from \"./atoms/atomWithKVStorage\";\n\nexport function deepEqualAtom<T>(a: Atom<T>) {\n  return selectAtom(\n    a,\n    (v) => v,\n    (a, b) => deepEqual(a, b),\n  );\n}\n\nexport const workspaceLayoutAtom = atomWithKVStorage<SplitLayoutLayout>(\n  \"workspace_layout\",\n  \"horizontal\",\n);\n\nexport const updateAvailableAtom = atom<Omit<UpdateInfo, \"replyEventId\"> | null>(null);\n"
  },
  {
    "path": "src-web/lib/capitalize.ts",
    "content": "export function capitalize(str: string): string {\n  return str\n    .split(\" \")\n    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))\n    .join(\" \");\n}\n"
  },
  {
    "path": "src-web/lib/clamp.ts",
    "content": "export function clamp(value: number, min: number, max: number): number {\n  return Math.min(Math.max(value, min), max);\n}\n"
  },
  {
    "path": "src-web/lib/color.ts",
    "content": "import type { Color } from \"@yaakapp-internal/plugins\";\n\nconst colors: Record<Color, boolean> = {\n  primary: true,\n  secondary: true,\n  success: true,\n  notice: true,\n  warning: true,\n  danger: true,\n  info: true,\n};\n\nexport function stringToColor(str: string | null): Color | null {\n  if (!str) return null;\n  const strLower = str.toLowerCase();\n  if (strLower in colors) {\n    return strLower as Color;\n  }\n  return null;\n}\n"
  },
  {
    "path": "src-web/lib/confirm.ts",
    "content": "import type { ConfirmProps } from \"../components/core/Confirm\";\nimport { Confirm } from \"../components/core/Confirm\";\nimport type { DialogProps } from \"../components/core/Dialog\";\nimport { showDialog } from \"./dialog\";\n\ntype ConfirmArgs = {\n  id: string;\n} & Pick<DialogProps, \"title\" | \"description\" | \"size\"> &\n  Pick<ConfirmProps, \"color\" | \"confirmText\" | \"requireTyping\">;\n\nexport async function showConfirm({\n  color,\n  confirmText,\n  requireTyping,\n  size = \"sm\",\n  ...extraProps\n}: ConfirmArgs) {\n  return new Promise((onResult: ConfirmProps[\"onResult\"]) => {\n    showDialog({\n      ...extraProps,\n      hideX: true,\n      size,\n      disableBackdropClose: true, // Prevent accidental dismisses\n      render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),\n    });\n  });\n}\n\nexport async function showConfirmDelete({ confirmText, color, ...extraProps }: ConfirmArgs) {\n  return showConfirm({\n    color: color ?? \"danger\",\n    confirmText: confirmText ?? \"Delete\",\n    ...extraProps,\n  });\n}\n"
  },
  {
    "path": "src-web/lib/constants.ts",
    "content": "export const HEADER_SIZE_MD = \"30px\";\nexport const HEADER_SIZE_LG = \"40px\";\n\nexport const WINDOW_CONTROLS_WIDTH = \"10.5rem\";\n"
  },
  {
    "path": "src-web/lib/contentType.ts",
    "content": "import MimeType from \"whatwg-mimetype\";\nimport type { EditorProps } from \"../components/core/Editor/Editor\";\n\nexport function languageFromContentType(\n  contentType: string | null,\n  content: string | null = null,\n): EditorProps[\"language\"] {\n  const justContentType = contentType?.split(\";\")[0] ?? contentType ?? \"\";\n  if (justContentType.includes(\"json\")) {\n    return \"json\";\n  }\n  if (justContentType.includes(\"xml\")) {\n    return \"xml\";\n  }\n  if (justContentType.includes(\"html\")) {\n    const detected = languageFromContent(content);\n    if (detected === \"xml\") {\n      // If it's detected as XML, but is already HTML, don't change it\n      return \"html\";\n    }\n    return detected;\n  }\n  if (justContentType.includes(\"javascript\")) {\n    // Sometimes `application/javascript` returns JSON, so try detecting that\n    return languageFromContent(content, \"javascript\");\n  }\n  if (justContentType.includes(\"markdown\")) {\n    return \"markdown\";\n  }\n\n  return languageFromContent(content, \"text\");\n}\n\nexport function languageFromContent(\n  content: string | null,\n  fallback?: EditorProps[\"language\"],\n): EditorProps[\"language\"] {\n  if (content == null) return \"text\";\n\n  const firstBytes = content.slice(0, 20).trim();\n\n  if (firstBytes.startsWith(\"{\") || firstBytes.startsWith(\"[\")) {\n    return \"json\";\n  }\n  if (\n    firstBytes.toLowerCase().startsWith(\"<!doctype\") ||\n    firstBytes.toLowerCase().startsWith(\"<html\")\n  ) {\n    return \"html\";\n  }\n  if (firstBytes.startsWith(\"<\")) {\n    return \"xml\";\n  }\n\n  return fallback;\n}\n\nexport function isJSON(content: string | null | undefined): boolean {\n  if (typeof content !== \"string\") return false;\n\n  try {\n    JSON.parse(content);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nexport function isProbablyTextContentType(contentType: string | null): boolean {\n  if (contentType == null) return false;\n\n  const mimeType = getMimeTypeFromContentType(contentType).essence;\n  const normalized = mimeType.toLowerCase();\n\n  // Check if it starts with \"text/\"\n  if (normalized.startsWith(\"text/\")) {\n    return true;\n  }\n\n  // Common text mimetypes and suffixes\n  return [\n    \"application/json\",\n    \"application/xml\",\n    \"application/javascript\",\n    \"application/yaml\",\n    \"+json\",\n    \"+xml\",\n    \"+yaml\",\n    \"+text\",\n  ].some((textType) => normalized === textType || normalized.endsWith(textType));\n}\n\nexport function getMimeTypeFromContentType(contentType: string): MimeType {\n  try {\n    return new MimeType(contentType);\n  } catch {\n    return new MimeType(\"text/plain\");\n  }\n}\n"
  },
  {
    "path": "src-web/lib/copy.ts",
    "content": "import { clear, writeText } from \"@tauri-apps/plugin-clipboard-manager\";\nimport { showToast } from \"./toast\";\n\nexport function copyToClipboard(\n  text: string | null,\n  { disableToast }: { disableToast?: boolean } = {},\n) {\n  if (text == null) {\n    clear().catch(console.error);\n  } else {\n    writeText(text).catch(console.error);\n  }\n\n  if (text !== \"\" && !disableToast) {\n    showToast({\n      id: \"copied\",\n      color: \"success\",\n      icon: \"copy\",\n      message: \"Copied to clipboard\",\n    });\n  }\n}\n"
  },
  {
    "path": "src-web/lib/createRequestAndNavigate.tsx",
    "content": "import type { GrpcRequest, HttpRequest, WebsocketRequest } from \"@yaakapp-internal/models\";\nimport { createWorkspaceModel } from \"@yaakapp-internal/models\";\nimport { activeRequestAtom } from \"../hooks/useActiveRequest\";\nimport { jotaiStore } from \"./jotai\";\nimport { router } from \"./router\";\n\nexport async function createRequestAndNavigate<\n  T extends HttpRequest | GrpcRequest | WebsocketRequest,\n>(patch: Partial<T> & Pick<T, \"model\" | \"workspaceId\">) {\n  const activeRequest = jotaiStore.get(activeRequestAtom);\n\n  if (patch.sortPriority === undefined) {\n    if (activeRequest != null) {\n      // Place below the currently active request\n      patch.sortPriority = activeRequest.sortPriority;\n    } else {\n      // Place at the very top\n      patch.sortPriority = -Date.now();\n    }\n  }\n  patch.folderId = patch.folderId || activeRequest?.folderId;\n\n  const newId = await createWorkspaceModel(patch);\n\n  await router.navigate({\n    to: \"/workspaces/$workspaceId\",\n    params: { workspaceId: patch.workspaceId },\n    search: (prev) => ({ ...prev, request_id: newId }),\n  });\n  return newId;\n}\n"
  },
  {
    "path": "src-web/lib/data/charsets.ts",
    "content": "export const charsets = [\n  \"utf-8\",\n  \"us-ascii\",\n  \"950\",\n  \"ASMO-708\",\n  \"CP1026\",\n  \"CP870\",\n  \"DOS-720\",\n  \"DOS-862\",\n  \"EUC-CN\",\n  \"IBM437\",\n  \"Johab\",\n  \"Windows-1252\",\n  \"X-EBCDIC-Spain\",\n  \"big5\",\n  \"cp866\",\n  \"csISO2022JP\",\n  \"ebcdic-cp-us\",\n  \"euc-kr\",\n  \"gb2312\",\n  \"hz-gb-2312\",\n  \"ibm737\",\n  \"ibm775\",\n  \"ibm850\",\n  \"ibm852\",\n  \"ibm857\",\n  \"ibm861\",\n  \"ibm869\",\n  \"iso-2022-jp\",\n  \"iso-2022-jp\",\n  \"iso-2022-kr\",\n  \"iso-8859-1\",\n  \"iso-8859-15\",\n  \"iso-8859-2\",\n  \"iso-8859-3\",\n  \"iso-8859-4\",\n  \"iso-8859-5\",\n  \"iso-8859-6\",\n  \"iso-8859-7\",\n  \"iso-8859-8\",\n  \"iso-8859-8-i\",\n  \"iso-8859-9\",\n  \"koi8-r\",\n  \"koi8-u\",\n  \"ks_c_5601-1987\",\n  \"macintosh\",\n  \"shift_jis\",\n  \"unicode\",\n  \"unicodeFFFE\",\n  \"utf-7\",\n  \"windows-1250\",\n  \"windows-1251\",\n  \"windows-1253\",\n  \"windows-1254\",\n  \"windows-1255\",\n  \"windows-1256\",\n  \"windows-1257\",\n  \"windows-1258\",\n  \"windows-874\",\n  \"x-Chinese-CNS\",\n  \"x-Chinese-Eten\",\n  \"x-EBCDIC-Arabic\",\n  \"x-EBCDIC-CyrillicRussian\",\n  \"x-EBCDIC-CyrillicSerbianBulgarian\",\n  \"x-EBCDIC-DenmarkNorway\",\n  \"x-EBCDIC-FinlandSweden\",\n  \"x-EBCDIC-Germany\",\n  \"x-EBCDIC-Greek\",\n  \"x-EBCDIC-GreekModern\",\n  \"x-EBCDIC-Hebrew\",\n  \"x-EBCDIC-Icelandic\",\n  \"x-EBCDIC-Italy\",\n  \"x-EBCDIC-JapaneseAndJapaneseLatin\",\n  \"x-EBCDIC-JapaneseAndKana\",\n  \"x-EBCDIC-JapaneseAndUSCanada\",\n  \"x-EBCDIC-JapaneseKatakana\",\n  \"x-EBCDIC-KoreanAndKoreanExtended\",\n  \"x-EBCDIC-KoreanExtended\",\n  \"x-EBCDIC-SimplifiedChinese\",\n  \"x-EBCDIC-Thai\",\n  \"x-EBCDIC-TraditionalChinese\",\n  \"x-EBCDIC-Turkish\",\n  \"x-EBCDIC-UK\",\n  \"x-Europa\",\n  \"x-IA5\",\n  \"x-IA5-German\",\n  \"x-IA5-Norwegian\",\n  \"x-IA5-Swedish\",\n  \"x-ebcdic-cp-us-euro\",\n  \"x-ebcdic-denmarknorway-euro\",\n  \"x-ebcdic-finlandsweden-euro\",\n  \"x-ebcdic-finlandsweden-euro\",\n  \"x-ebcdic-france-euro\",\n  \"x-ebcdic-germany-euro\",\n  \"x-ebcdic-icelandic-euro\",\n  \"x-ebcdic-international-euro\",\n  \"x-ebcdic-italy-euro\",\n  \"x-ebcdic-spain-euro\",\n  \"x-ebcdic-uk-euro\",\n  \"x-euc-jp\",\n  \"x-iscii-as\",\n  \"x-iscii-be\",\n  \"x-iscii-de\",\n  \"x-iscii-gu\",\n  \"x-iscii-ka\",\n  \"x-iscii-ma\",\n  \"x-iscii-or\",\n  \"x-iscii-pa\",\n  \"x-iscii-ta\",\n  \"x-iscii-te\",\n  \"x-mac-arabic\",\n  \"x-mac-ce\",\n  \"x-mac-chinesesimp\",\n  \"x-mac-cyrillic\",\n  \"x-mac-greek\",\n  \"x-mac-hebrew\",\n  \"x-mac-icelandic\",\n  \"x-mac-japanese\",\n  \"x-mac-korean\",\n  \"x-mac-turkish\",\n];\n"
  },
  {
    "path": "src-web/lib/data/connections.ts",
    "content": "export const connections = [\"close\", \"keep-alive\"];\n"
  },
  {
    "path": "src-web/lib/data/encodings.ts",
    "content": "export const encodings = [\"*\", \"gzip\", \"compress\", \"deflate\", \"br\", \"zstd\", \"identity\"];\n"
  },
  {
    "path": "src-web/lib/data/headerNames.ts",
    "content": "import type { GenericCompletionOption } from \"@yaakapp-internal/plugins\";\n\nexport const headerNames: (GenericCompletionOption | string)[] = [\n  {\n    type: \"constant\",\n    label: \"Content-Type\",\n    info: \"The original media type of the resource (prior to any content encoding applied for sending)\",\n  },\n  {\n    type: \"constant\",\n    label: \"Content-Length\",\n    info: \"The size of the message body, in bytes, sent to the recipient\",\n  },\n  {\n    type: \"constant\",\n    label: \"Accept\",\n    info:\n      \"The content types, expressed as MIME types, the client is able to understand. \" +\n      \"The server uses content negotiation to select one of the proposals and informs \" +\n      \"the client of the choice with the Content-Type response header. Browsers set required \" +\n      \"values for this header based on the context of the request. For example, a browser uses \" +\n      \"different values in a request when fetching a CSS stylesheet, image, video, or a script.\",\n  },\n  {\n    type: \"constant\",\n    label: \"Accept-Encoding\",\n    info:\n      \"The content encoding (usually a compression algorithm) that the client can understand. \" +\n      \"The server uses content negotiation to select one of the proposals and informs the client \" +\n      \"of that choice with the Content-Encoding response header.\",\n  },\n  {\n    type: \"constant\",\n    label: \"Accept-Language\",\n    info:\n      \"The natural language and locale that the client prefers. The server uses content \" +\n      \"negotiation to select one of the proposals and informs the client of the choice with \" +\n      \"the Content-Language response header.\",\n  },\n  {\n    type: \"constant\",\n    label: \"Authorization\",\n    info: \"Provide credentials that authenticate a user agent with a server, allowing access to a protected resource.\",\n  },\n  \"Cache-Control\",\n  \"Cookie\",\n  \"Connection\",\n  \"Content-MD5\",\n  \"Date\",\n  \"Expect\",\n  \"Forwarded\",\n  \"From\",\n  \"Host\",\n  \"If-Match\",\n  \"If-Modified-Since\",\n  \"If-None-Match\",\n  \"If-Range\",\n  \"If-Unmodified-Since\",\n  \"Max-Forwards\",\n  \"Origin\",\n  \"Pragma\",\n  \"Proxy-Authorization\",\n  \"Range\",\n  \"Referer\",\n  \"TE\",\n  \"User-Agent\",\n  \"Upgrade\",\n  \"Via\",\n  \"Warning\",\n];\n"
  },
  {
    "path": "src-web/lib/data/mimetypes.ts",
    "content": "export const mimeTypes = [\n  \"application/json\",\n  \"application/xml\",\n  \"application/x-www-form-urlencoded\",\n  \"multipart/form-data\",\n  \"multipart/byteranges\",\n  \"application/octet-stream\",\n  \"text/plain\",\n  \"application/javascript\",\n  \"application/pdf\",\n  \"text/html\",\n  \"image/png\",\n  \"image/jpeg\",\n  \"image/gif\",\n  \"image/webp\",\n  \"text/css\",\n  \"application/x-pkcs12\",\n  \"application/xhtml+xml\",\n  \"application/andrew-inset\",\n  \"application/applixware\",\n  \"application/atom+xml\",\n  \"application/atomcat+xml\",\n  \"application/atomsvc+xml\",\n  \"application/bdoc\",\n  \"application/cu-seeme\",\n  \"application/davmount+xml\",\n  \"application/docbook+xml\",\n  \"application/dssc+xml\",\n  \"application/ecmascript\",\n  \"application/epub+zip\",\n  \"application/exi\",\n  \"application/font-tdpfr\",\n  \"application/font-woff\",\n  \"application/font-woff2\",\n  \"application/geo+json\",\n  \"application/graphql\",\n  \"application/java-serialized-object\",\n  \"application/json5\",\n  \"application/jsonml+json\",\n  \"application/ld+json\",\n  \"application/lost+xml\",\n  \"application/manifest+json\",\n  \"application/mp4\",\n  \"application/msword\",\n  \"application/mxf\",\n  \"application/n-triples\",\n  \"application/n-quads\",\n  \"application/oda\",\n  \"application/ogg\",\n  \"application/pgp-encrypted\",\n  \"application/pgp-signature\",\n  \"application/pics-rules\",\n  \"application/pkcs10\",\n  \"application/pkcs7-mime\",\n  \"application/pkcs7-signature\",\n  \"application/pkcs8\",\n  \"application/postscript\",\n  \"application/pskc+xml\",\n  \"application/rdf+xml\",\n  \"application/resource-lists+xml\",\n  \"application/resource-lists-diff+xml\",\n  \"application/rls-services+xml\",\n  \"application/rsd+xml\",\n  \"application/rss+xml\",\n  \"application/rtf\",\n  \"application/sdp\",\n  \"application/shf+xml\",\n  \"application/timestamped-data\",\n  \"application/trig\",\n  \"application/vnd.android.package-archive\",\n  \"application/vnd.api+json\",\n  \"application/vnd.apple.installer+xml\",\n  \"application/vnd.apple.mpegurl\",\n  \"application/vnd.apple.pkpass\",\n  \"application/vnd.bmi\",\n  \"application/vnd.curl.car\",\n  \"application/vnd.curl.pcurl\",\n  \"application/vnd.dna\",\n  \"application/vnd.google-apps.document\",\n  \"application/vnd.google-apps.presentation\",\n  \"application/vnd.google-apps.spreadsheet\",\n  \"application/vnd.hal+xml\",\n  \"application/vnd.handheld-entertainment+xml\",\n  \"application/vnd.macports.portpkg\",\n  \"application/vnd.unity\",\n  \"application/vnd.zul\",\n  \"application/widget\",\n  \"application/wsdl+xml\",\n  \"application/x-7z-compressed\",\n  \"application/x-ace-compressed\",\n  \"application/x-bittorrent\",\n  \"application/x-bzip\",\n  \"application/x-bzip2\",\n  \"application/x-cfs-compressed\",\n  \"application/x-chrome-extension\",\n  \"application/x-cocoa\",\n  \"application/x-envoy\",\n  \"application/x-eva\",\n  \"font/opentype\",\n  \"application/x-gca-compressed\",\n  \"application/x-gtar\",\n  \"application/x-hdf\",\n  \"application/x-httpd-php\",\n  \"application/x-install-instructions\",\n  \"application/x-latex\",\n  \"application/x-lua-bytecode\",\n  \"application/x-lzh-compressed\",\n  \"application/x-ms-application\",\n  \"application/x-ms-shortcut\",\n  \"application/x-ndjson\",\n  \"application/x-perl\",\n  \"application/x-pkcs7-certificates\",\n  \"application/x-pkcs7-certreqresp\",\n  \"application/x-rar-compressed\",\n  \"application/x-sh\",\n  \"application/x-sql\",\n  \"application/x-subrip\",\n  \"application/x-t3vm-image\",\n  \"application/x-tads\",\n  \"application/x-tar\",\n  \"application/x-tcl\",\n  \"application/x-tex\",\n  \"application/x-x509-ca-cert\",\n  \"application/xop+xml\",\n  \"application/xslt+xml\",\n  \"application/zip\",\n  \"audio/3gpp\",\n  \"audio/adpcm\",\n  \"audio/basic\",\n  \"audio/midi\",\n  \"audio/mpeg\",\n  \"audio/mp4\",\n  \"audio/ogg\",\n  \"audio/silk\",\n  \"audio/wave\",\n  \"audio/webm\",\n  \"audio/x-aac\",\n  \"audio/x-aiff\",\n  \"audio/x-caf\",\n  \"audio/x-flac\",\n  \"audio/xm\",\n  \"image/bmp\",\n  \"image/cgm\",\n  \"image/sgi\",\n  \"image/svg+xml\",\n  \"image/tiff\",\n  \"image/x-3ds\",\n  \"image/x-freehand\",\n  \"image/x-icon\",\n  \"image/x-jng\",\n  \"image/x-mrsid-image\",\n  \"image/x-pcx\",\n  \"image/x-pict\",\n  \"image/x-rgb\",\n  \"image/x-tga\",\n  \"message/rfc822\",\n  \"text/cache-manifest\",\n  \"text/calendar\",\n  \"text/coffeescript\",\n  \"text/csv\",\n  \"text/hjson\",\n  \"text/jade\",\n  \"text/jsx\",\n  \"text/less\",\n  \"text/mathml\",\n  \"text/n3\",\n  \"text/richtext\",\n  \"text/sgml\",\n  \"text/slim\",\n  \"text/stylus\",\n  \"text/tab-separated-values\",\n  \"text/turtle\",\n  \"text/uri-list\",\n  \"text/vcard\",\n  \"text/vnd.curl\",\n  \"text/vnd.fly\",\n  \"text/vtt\",\n  \"text/x-asm\",\n  \"text/x-c\",\n  \"text/x-component\",\n  \"text/x-fortran\",\n  \"text/x-handlebars-template\",\n  \"text/x-java-source\",\n  \"text/x-lua\",\n  \"text/x-markdown\",\n  \"text/x-nfo\",\n  \"text/x-opml\",\n  \"text/x-pascal\",\n  \"text/x-processing\",\n  \"text/x-sass\",\n  \"text/x-scss\",\n  \"text/x-vcalendar\",\n  \"text/xml\",\n  \"text/yaml\",\n  \"video/3gpp\",\n  \"video/3gpp2\",\n  \"video/h261\",\n  \"video/h263\",\n  \"video/h264\",\n  \"video/jpeg\",\n  \"video/jpm\",\n  \"video/mj2\",\n  \"video/mp2t\",\n  \"video/mp4\",\n  \"video/mpeg\",\n  \"video/ogg\",\n  \"video/quicktime\",\n  \"video/webm\",\n  \"video/x-f4v\",\n  \"video/x-fli\",\n  \"video/x-flv\",\n  \"video/x-m4v\",\n];\n"
  },
  {
    "path": "src-web/lib/defaultHeaders.ts",
    "content": "import type { HttpRequestHeader } from \"@yaakapp-internal/models\";\nimport { invokeCmd } from \"./tauri\";\n\n/**\n * Global default headers fetched from the backend.\n * These are static and fetched once on module load.\n */\nexport const defaultHeaders: HttpRequestHeader[] = await invokeCmd(\"cmd_default_headers\");\n"
  },
  {
    "path": "src-web/lib/deleteModelWithConfirm.tsx",
    "content": "import type { AnyModel } from \"@yaakapp-internal/models\";\nimport { deleteModel, modelTypeLabel } from \"@yaakapp-internal/models\";\nimport { InlineCode } from \"../components/core/InlineCode\";\nimport { Prose } from \"../components/Prose\";\nimport { showConfirmDelete } from \"./confirm\";\nimport { pluralizeCount } from \"./pluralize\";\nimport { resolvedModelName } from \"./resolvedModelName\";\n\nexport async function deleteModelWithConfirm(\n  model: AnyModel | AnyModel[] | null,\n  options: { confirmName?: string } = {},\n): Promise<boolean> {\n  if (model == null) {\n    console.warn(\"Tried to delete null model\");\n    return false;\n  }\n  const models = Array.isArray(model) ? model : [model];\n  const firstModel = models[0];\n  if (firstModel == null) return false;\n\n  const descriptor =\n    models.length === 1 ? modelTypeLabel(firstModel) : pluralizeCount(\"Item\", models.length);\n  const confirmed = await showConfirmDelete({\n    id: `delete-model-${models.map((m) => m.id).join(\",\")}`,\n    title: `Delete ${descriptor}`,\n    requireTyping: options.confirmName,\n    description: (\n      <>\n        Permanently delete{\" \"}\n        {models.length === 1 ? (\n          <>\n            <InlineCode>{resolvedModelName(firstModel)}</InlineCode>?\n          </>\n        ) : models.length < 10 ? (\n          <>\n            the following?\n            <Prose className=\"mt-2\">\n              <ul>\n                {models.map((m) => (\n                  <li key={m.id}>\n                    <InlineCode>{resolvedModelName(m)}</InlineCode>\n                  </li>\n                ))}\n              </ul>\n            </Prose>\n          </>\n        ) : (\n          `all ${pluralizeCount(\"item\", models.length)}?`\n        )}\n      </>\n    ),\n  });\n\n  if (!confirmed) {\n    return false;\n  }\n\n  await Promise.allSettled(models.map((m) => deleteModel(m)));\n  return true;\n}\n"
  },
  {
    "path": "src-web/lib/dialog.ts",
    "content": "import { atom } from \"jotai\";\nimport type { DialogInstance } from \"../components/Dialogs\";\nimport { jotaiStore } from \"./jotai\";\n\nexport const dialogsAtom = atom<DialogInstance[]>([]);\n\nexport function toggleDialog({ id, ...props }: DialogInstance) {\n  const dialogs = jotaiStore.get(dialogsAtom);\n  if (dialogs.some((d) => d.id === id)) {\n    hideDialog(id);\n  } else {\n    showDialog({ id, ...props });\n  }\n}\n\nexport function showDialog({ id, ...props }: DialogInstance) {\n  jotaiStore.set(dialogsAtom, (a) => [...a.filter((d) => d.id !== id), { id, ...props }]);\n}\n\nexport function hideDialog(id: string) {\n  jotaiStore.set(dialogsAtom, (a) => a.filter((d) => d.id !== id));\n}\n"
  },
  {
    "path": "src-web/lib/diffYaml.ts",
    "content": "import type { SyncModel } from \"@yaakapp-internal/git\";\nimport { stringify } from \"yaml\";\n\n/**\n * Convert a SyncModel to a clean YAML string for diffing.\n * Removes noisy fields like updatedAt that change on every edit.\n */\nexport function modelToYaml(model: SyncModel | null): string {\n  if (!model) return \"\";\n\n  return stringify(model, {\n    indent: 2,\n    lineWidth: 0,\n  });\n}\n"
  },
  {
    "path": "src-web/lib/dnd.ts",
    "content": "import type { DragMoveEvent } from \"@dnd-kit/core\";\n\nexport function computeSideForDragMove(\n  id: string,\n  e: DragMoveEvent,\n  orientation: \"vertical\" | \"horizontal\" = \"vertical\",\n): \"before\" | \"after\" | null {\n  if (e.over == null || e.over.id !== id) {\n    return null;\n  }\n  if (e.active.rect.current.initial == null) return null;\n\n  const overRect = e.over.rect;\n\n  if (orientation === \"horizontal\") {\n    // For horizontal layouts (tabs side-by-side), use left/right logic\n    const activeLeft =\n      e.active.rect.current.translated?.left ?? e.active.rect.current.initial.left + e.delta.x;\n    const pointerX = activeLeft + e.active.rect.current.initial.width / 2;\n\n    const hoverLeft = overRect.left;\n    const hoverRight = overRect.right;\n    const hoverMiddleX = hoverLeft + (hoverRight - hoverLeft) / 2;\n\n    return pointerX < hoverMiddleX ? \"before\" : \"after\"; // 'before' = left, 'after' = right\n  } else {\n    // For vertical layouts, use top/bottom logic\n    const activeTop =\n      e.active.rect.current.translated?.top ?? e.active.rect.current.initial.top + e.delta.y;\n    const pointerY = activeTop + e.active.rect.current.initial.height / 2;\n\n    const hoverTop = overRect.top;\n    const hoverBottom = overRect.bottom;\n    const hoverMiddleY = (hoverBottom - hoverTop) / 2;\n    const hoverClientY = pointerY - hoverTop;\n\n    return hoverClientY < hoverMiddleY ? \"before\" : \"after\";\n  }\n}\n"
  },
  {
    "path": "src-web/lib/duplicateRequestOrFolderAndNavigate.tsx",
    "content": "import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from \"@yaakapp-internal/models\";\nimport { duplicateModel } from \"@yaakapp-internal/models\";\nimport { activeWorkspaceIdAtom } from \"../hooks/useActiveWorkspace\";\nimport { jotaiStore } from \"./jotai\";\nimport { navigateToRequestOrFolderOrWorkspace } from \"./setWorkspaceSearchParams\";\n\nexport async function duplicateRequestOrFolderAndNavigate(\n  model: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,\n) {\n  if (model == null) {\n    throw new Error(\"Cannot duplicate null item\");\n  }\n\n  const newId = await duplicateModel(model);\n  const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);\n  if (workspaceId == null || model.model === \"folder\") return;\n\n  navigateToRequestOrFolderOrWorkspace(newId, model.model);\n}\n"
  },
  {
    "path": "src-web/lib/editEnvironment.tsx",
    "content": "import type { Environment, EnvironmentVariable } from \"@yaakapp-internal/models\";\nimport { updateModel } from \"@yaakapp-internal/models\";\nimport { openFolderSettings } from \"../commands/openFolderSettings\";\nimport type { PairEditorHandle } from \"../components/core/PairEditor\";\nimport { ensurePairId } from \"../components/core/PairEditor.util\";\nimport { EnvironmentEditDialog } from \"../components/EnvironmentEditDialog\";\nimport { environmentsBreakdownAtom } from \"../hooks/useEnvironmentsBreakdown\";\nimport { toggleDialog } from \"./dialog\";\nimport { jotaiStore } from \"./jotai\";\n\ninterface Options {\n  addOrFocusVariable?: EnvironmentVariable;\n}\n\nexport async function editEnvironment(\n  initialEnvironment: Environment | null,\n  options: Options = {},\n) {\n  if (initialEnvironment?.parentModel === \"folder\" && initialEnvironment.parentId != null) {\n    openFolderSettings(initialEnvironment.parentId, \"variables\");\n  } else {\n    const { addOrFocusVariable } = options;\n    const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);\n    let environment = initialEnvironment ?? baseEnvironment;\n    let focusId: string | null = null;\n\n    if (addOrFocusVariable && environment != null) {\n      const existing = environment.variables.find(\n        (v) => v.id === addOrFocusVariable.id || v.name === addOrFocusVariable.name,\n      );\n      if (existing) {\n        focusId = existing.id ?? null;\n      } else {\n        const newVar = ensurePairId(addOrFocusVariable);\n        environment = { ...environment, variables: [...environment.variables, newVar] };\n        await updateModel(environment);\n        environment.variables.push(newVar);\n        focusId = newVar.id;\n      }\n    }\n\n    let didFocusVariable = false;\n\n    toggleDialog({\n      id: \"environment-editor\",\n      noPadding: true,\n      size: \"lg\",\n      className: \"h-[90vh] max-h-[60rem]\",\n      render: () => (\n        <EnvironmentEditDialog\n          initialEnvironmentId={environment?.id ?? null}\n          setRef={(pairEditor: PairEditorHandle | null) => {\n            if (focusId && !didFocusVariable) {\n              pairEditor?.focusValue(focusId);\n              didFocusVariable = true;\n            }\n          }}\n        />\n      ),\n    });\n  }\n}\n"
  },
  {
    "path": "src-web/lib/encryption.ts",
    "content": "import { parseTemplate } from \"@yaakapp-internal/templates\";\nimport { activeEnvironmentIdAtom } from \"../hooks/useActiveEnvironment\";\nimport { activeWorkspaceIdAtom } from \"../hooks/useActiveWorkspace\";\nimport { jotaiStore } from \"./jotai\";\nimport { invokeCmd } from \"./tauri\";\n\nexport function analyzeTemplate(template: string): \"global_secured\" | \"local_secured\" | \"insecure\" {\n  let secureTags = 0;\n  let insecureTags = 0;\n  let totalTags = 0;\n  for (const t of parseTemplate(template).tokens) {\n    if (t.type === \"eof\") continue;\n\n    totalTags++;\n    if (t.type === \"tag\" && t.val.type === \"fn\" && t.val.name === \"secure\") {\n      secureTags++;\n    } else if (t.type === \"tag\" && t.val.type === \"var\") {\n      // Variables are secure\n    } else if (t.type === \"tag\" && t.val.type === \"bool\") {\n      // Booleans are secure\n    } else {\n      insecureTags++;\n    }\n  }\n\n  if (secureTags === 1 && totalTags === 1) {\n    return \"global_secured\";\n  }\n  if (insecureTags === 0) {\n    return \"local_secured\";\n  }\n  return \"insecure\";\n}\n\nexport async function convertTemplateToInsecure(template: string) {\n  if (template === \"\") {\n    return \"\";\n  }\n\n  const workspaceId = jotaiStore.get(activeWorkspaceIdAtom) ?? \"n/a\";\n  const environmentId = jotaiStore.get(activeEnvironmentIdAtom) ?? null;\n  return invokeCmd<string>(\"cmd_decrypt_template\", { template, workspaceId, environmentId });\n}\n\nexport async function convertTemplateToSecure(template: string): Promise<string> {\n  if (template === \"\") {\n    return \"\";\n  }\n\n  if (analyzeTemplate(template) === \"global_secured\") {\n    return template;\n  }\n\n  const workspaceId = jotaiStore.get(activeWorkspaceIdAtom) ?? \"n/a\";\n  const environmentId = jotaiStore.get(activeEnvironmentIdAtom) ?? null;\n  return invokeCmd<string>(\"cmd_secure_template\", { template, workspaceId, environmentId });\n}\n"
  },
  {
    "path": "src-web/lib/fireAndForget.ts",
    "content": "import { showErrorToast } from \"./toast\";\n\n/**\n * Handles a fire-and-forget promise by catching and reporting errors\n * via console.error and a toast notification.\n */\nexport function fireAndForget(promise: Promise<unknown>) {\n  promise.catch((err: unknown) => {\n    console.error(\"Unhandled async error:\", err);\n    showErrorToast({\n      id: \"async-error\",\n      title: \"Unexpected Error\",\n      message: err instanceof Error ? err.message : String(err),\n    });\n  });\n}\n"
  },
  {
    "path": "src-web/lib/formatters.ts",
    "content": "import vkBeautify from \"vkbeautify\";\nimport { invokeCmd } from \"./tauri\";\n\nexport async function tryFormatJson(text: string): Promise<string> {\n  if (text === \"\") return text;\n\n  try {\n    const result = await invokeCmd<string>(\"cmd_format_json\", { text });\n    return result;\n  } catch (err) {\n    console.warn(\"Failed to format JSON\", err);\n  }\n\n  try {\n    return JSON.stringify(JSON.parse(text), null, 2);\n  } catch (err) {\n    console.log(\"JSON beautify failed\", err);\n  }\n\n  return text;\n}\n\nexport async function tryFormatGraphql(text: string): Promise<string> {\n  if (text === \"\") return text;\n\n  try {\n    return await invokeCmd<string>(\"cmd_format_graphql\", { text });\n  } catch (err) {\n    console.warn(\"Failed to format GraphQL\", err);\n  }\n\n  return text;\n}\n\nexport async function tryFormatXml(text: string): Promise<string> {\n  if (text === \"\") return text;\n\n  try {\n    return vkBeautify.xml(text, \"  \");\n  } catch (err) {\n    console.warn(\"Failed to format XML\", err);\n  }\n\n  return text;\n}\n"
  },
  {
    "path": "src-web/lib/generateId.ts",
    "content": "import { customAlphabet } from \"nanoid\";\n\nconst nanoid = customAlphabet(\"023456789abcdefghijkmnpqrstuvwxyzABCDEFGHIJKMNPQRSTUVWXYZ\", 10);\n\nexport function generateId(): string {\n  return nanoid();\n}\n"
  },
  {
    "path": "src-web/lib/getNodeText.ts",
    "content": "import type { ReactNode } from \"react\";\n\n/**\n * Get the text content from a ReactNode\n * https://stackoverflow.com/questions/50428910/get-text-content-from-node-in-react\n */\nexport function getNodeText(node: ReactNode): string {\n  if (typeof node === \"string\" || typeof node === \"number\") {\n    return String(node);\n  }\n\n  if (Array.isArray(node)) {\n    return node.map(getNodeText).join(\"\");\n  }\n\n  if (typeof node === \"object\" && node) {\n    // oxlint-disable-next-line no-explicit-any\n    return getNodeText((node as any).props.children);\n  }\n\n  return \"\";\n}\n"
  },
  {
    "path": "src-web/lib/importData.tsx",
    "content": "import type { BatchUpsertResult } from \"@yaakapp-internal/models\";\nimport { Button } from \"../components/core/Button\";\nimport { FormattedError } from \"../components/core/FormattedError\";\nimport { VStack } from \"../components/core/Stacks\";\nimport { ImportDataDialog } from \"../components/ImportDataDialog\";\nimport { activeWorkspaceAtom } from \"../hooks/useActiveWorkspace\";\nimport { createFastMutation } from \"../hooks/useFastMutation\";\nimport { showAlert } from \"./alert\";\nimport { showDialog } from \"./dialog\";\nimport { jotaiStore } from \"./jotai\";\nimport { pluralizeCount } from \"./pluralize\";\nimport { router } from \"./router\";\nimport { invokeCmd } from \"./tauri\";\n\nexport const importData = createFastMutation({\n  mutationKey: [\"import_data\"],\n  onError: (err: string) => {\n    showAlert({\n      id: \"import-failed\",\n      title: \"Import Failed\",\n      size: \"md\",\n      body: <FormattedError>{err}</FormattedError>,\n    });\n  },\n  mutationFn: async () => {\n    return new Promise<void>((resolve, reject) => {\n      showDialog({\n        id: \"import\",\n        title: \"Import Data\",\n        size: \"sm\",\n        render: ({ hide }) => {\n          const importAndHide = async (filePath: string) => {\n            try {\n              const didImport = await performImport(filePath);\n              if (!didImport) {\n                return;\n              }\n              resolve();\n            } catch (err) {\n              reject(err);\n            } finally {\n              hide();\n            }\n          };\n          return <ImportDataDialog importData={importAndHide} />;\n        },\n      });\n    });\n  },\n});\n\nasync function performImport(filePath: string): Promise<boolean> {\n  const activeWorkspace = jotaiStore.get(activeWorkspaceAtom);\n  const imported = await invokeCmd<BatchUpsertResult>(\"cmd_import_data\", {\n    filePath,\n    workspaceId: activeWorkspace?.id,\n  });\n\n  const importedWorkspace = imported.workspaces[0];\n\n  showDialog({\n    id: \"import-complete\",\n    title: \"Import Complete\",\n    size: \"sm\",\n    hideX: true,\n    render: ({ hide }) => {\n      return (\n        <VStack space={3} className=\"pb-4\">\n          <ul className=\"list-disc pl-6\">\n            {imported.workspaces.length > 0 && (\n              <li>{pluralizeCount(\"Workspace\", imported.workspaces.length)}</li>\n            )}\n            {imported.environments.length > 0 && (\n              <li>{pluralizeCount(\"Environment\", imported.environments.length)}</li>\n            )}\n            {imported.folders.length > 0 && (\n              <li>{pluralizeCount(\"Folder\", imported.folders.length)}</li>\n            )}\n            {imported.httpRequests.length > 0 && (\n              <li>{pluralizeCount(\"HTTP Request\", imported.httpRequests.length)}</li>\n            )}\n            {imported.grpcRequests.length > 0 && (\n              <li>{pluralizeCount(\"GRPC Request\", imported.grpcRequests.length)}</li>\n            )}\n            {imported.websocketRequests.length > 0 && (\n              <li>{pluralizeCount(\"Websocket Request\", imported.websocketRequests.length)}</li>\n            )}\n          </ul>\n          <div>\n            <Button className=\"ml-auto\" onClick={hide} color=\"primary\">\n              Done\n            </Button>\n          </div>\n        </VStack>\n      );\n    },\n  });\n\n  if (importedWorkspace != null) {\n    const environmentId = imported.environments[0]?.id ?? null;\n    await router.navigate({\n      to: \"/workspaces/$workspaceId\",\n      params: { workspaceId: importedWorkspace.id },\n      search: { environment_id: environmentId },\n    });\n  }\n\n  return true;\n}\n"
  },
  {
    "path": "src-web/lib/initGlobalListeners.tsx",
    "content": "import { emit } from \"@tauri-apps/api/event\";\nimport { openUrl } from \"@tauri-apps/plugin-opener\";\nimport { debounce } from \"@yaakapp-internal/lib\";\nimport type {\n  FormInput,\n  InternalEvent,\n  JsonPrimitive,\n  ShowToastRequest,\n} from \"@yaakapp-internal/plugins\";\nimport { updateAllPlugins } from \"@yaakapp-internal/plugins\";\nimport type {\n  PluginUpdateNotification,\n  UpdateInfo,\n  UpdateResponse,\n  YaakNotification,\n} from \"@yaakapp-internal/tauri\";\nimport { openSettings } from \"../commands/openSettings\";\nimport { Button } from \"../components/core/Button\";\nimport { ButtonInfiniteLoading } from \"../components/core/ButtonInfiniteLoading\";\nimport { Icon } from \"../components/core/Icon\";\nimport { HStack, VStack } from \"../components/core/Stacks\";\n\n// Listen for toasts\nimport { listenToTauriEvent } from \"../hooks/useListenToTauriEvent\";\nimport { fireAndForget } from \"./fireAndForget\";\nimport { updateAvailableAtom } from \"./atoms\";\nimport { stringToColor } from \"./color\";\nimport { generateId } from \"./generateId\";\nimport { jotaiStore } from \"./jotai\";\nimport { showPrompt } from \"./prompt\";\nimport { showPromptForm } from \"./prompt-form\";\nimport { invokeCmd } from \"./tauri\";\nimport { showToast } from \"./toast\";\n\nexport function initGlobalListeners() {\n  listenToTauriEvent<ShowToastRequest>(\"show_toast\", (event) => {\n    showToast({ ...event.payload });\n  });\n\n  listenToTauriEvent(\"settings\", () => openSettings.mutate(null));\n\n  // Track active dynamic form dialogs so follow-up input updates can reach them\n  const activeForms = new Map<string, (inputs: FormInput[]) => void>();\n\n  // Listen for plugin events\n  listenToTauriEvent<InternalEvent>(\"plugin_event\", async ({ payload: event }) => {\n    if (event.payload.type === \"prompt_text_request\") {\n      const value = await showPrompt(event.payload);\n      const result: InternalEvent = {\n        id: generateId(),\n        replyId: event.id,\n        pluginName: event.pluginName,\n        pluginRefId: event.pluginRefId,\n        context: event.context,\n        payload: {\n          type: \"prompt_text_response\",\n          value,\n        },\n      };\n      await emit(event.id, result);\n    } else if (event.payload.type === \"prompt_form_request\") {\n      if (event.replyId != null) {\n        // Follow-up update from plugin runtime — update the active dialog's inputs\n        const updateInputs = activeForms.get(event.replyId);\n        if (updateInputs) {\n          updateInputs(event.payload.inputs);\n        }\n        return;\n      }\n\n      // Initial request — show the dialog with bidirectional support\n      const emitFormResponse = (values: Record<string, JsonPrimitive> | null, done: boolean) => {\n        const result: InternalEvent = {\n          id: generateId(),\n          replyId: event.id,\n          pluginName: event.pluginName,\n          pluginRefId: event.pluginRefId,\n          context: event.context,\n          payload: {\n            type: \"prompt_form_response\",\n            values,\n            done,\n          },\n        };\n        fireAndForget(emit(event.id, result));\n      };\n\n      const values = await showPromptForm({\n        id: event.payload.id,\n        title: event.payload.title,\n        description: event.payload.description,\n        size: event.payload.size,\n        inputs: event.payload.inputs,\n        confirmText: event.payload.confirmText,\n        cancelText: event.payload.cancelText,\n        onValuesChange: debounce((values) => emitFormResponse(values, false), 150),\n        onInputsUpdated: (cb) => activeForms.set(event.id, cb),\n      });\n\n      // Clean up and send final response\n      activeForms.delete(event.id);\n      emitFormResponse(values, true);\n    }\n  });\n\n  listenToTauriEvent<string>(\"update_installed\", async ({ payload: version }) => {\n    console.log(\"Got update installed event\", version);\n    showUpdateInstalledToast(version);\n  });\n\n  // Listen for update events\n  listenToTauriEvent<UpdateInfo>(\"update_available\", async ({ payload }) => {\n    console.log(\"Got update available\", payload);\n    fireAndForget(showUpdateAvailableToast(payload));\n  });\n\n  listenToTauriEvent<YaakNotification>(\"notification\", ({ payload }) => {\n    console.log(\"Got notification event\", payload);\n    showNotificationToast(payload);\n  });\n\n  // Listen for plugin update events\n  listenToTauriEvent<PluginUpdateNotification>(\"plugin_updates_available\", ({ payload }) => {\n    console.log(\"Got plugin updates event\", payload);\n    showPluginUpdatesToast(payload);\n  });\n\n  // Check for plugin initialization errors\n  fireAndForget(\n    invokeCmd<[string, string][]>(\"cmd_plugin_init_errors\").then((errors) => {\n      for (const [dir, message] of errors) {\n        const dirBasename = dir.split(\"/\").pop() ?? dir;\n        showToast({\n          id: `plugin-init-error-${dirBasename}`,\n          color: \"warning\",\n          timeout: null,\n          message: (\n            <VStack>\n              <h2 className=\"font-semibold\">Plugin failed to load</h2>\n              <p className=\"text-text-subtle text-sm\">\n                {dirBasename}: {message}\n              </p>\n            </VStack>\n          ),\n          action: ({ hide }) => (\n            <Button\n              size=\"xs\"\n              color=\"warning\"\n              variant=\"border\"\n              onClick={() => {\n                hide();\n                openSettings.mutate(\"plugins:installed\");\n              }}\n            >\n              View Plugins\n            </Button>\n          ),\n        });\n      }\n    }),\n  );\n}\n\nfunction showUpdateInstalledToast(version: string) {\n  const UPDATE_TOAST_ID = \"update-info\";\n\n  showToast({\n    id: UPDATE_TOAST_ID,\n    color: \"primary\",\n    timeout: null,\n    message: (\n      <VStack>\n        <h2 className=\"font-semibold\">Yaak {version} was installed</h2>\n        <p className=\"text-text-subtle text-sm\">Start using the new version now?</p>\n      </VStack>\n    ),\n    action: ({ hide }) => (\n      <ButtonInfiniteLoading\n        size=\"xs\"\n        className=\"mr-auto min-w-[5rem]\"\n        color=\"primary\"\n        loadingChildren=\"Restarting...\"\n        onClick={() => {\n          hide();\n          setTimeout(() => invokeCmd(\"cmd_restart\", {}), 200);\n        }}\n      >\n        Relaunch Yaak\n      </ButtonInfiniteLoading>\n    ),\n  });\n}\n\nasync function showUpdateAvailableToast(updateInfo: UpdateInfo) {\n  const UPDATE_TOAST_ID = \"update-info\";\n  const { version, replyEventId, downloaded } = updateInfo;\n\n  jotaiStore.set(updateAvailableAtom, { version, downloaded });\n\n  // Acknowledge the event, so we don't time out and try the fallback update logic\n  await emit<UpdateResponse>(replyEventId, { type: \"ack\" });\n\n  showToast({\n    id: UPDATE_TOAST_ID,\n    color: \"info\",\n    timeout: null,\n    message: (\n      <VStack>\n        <h2 className=\"font-semibold\">Yaak {version} is available</h2>\n        <p className=\"text-text-subtle text-sm\">\n          {downloaded ? \"Do you want to install\" : \"Download and install\"} the update?\n        </p>\n      </VStack>\n    ),\n    action: () => (\n      <HStack space={1.5}>\n        <ButtonInfiniteLoading\n          size=\"xs\"\n          color=\"info\"\n          className=\"min-w-[10rem]\"\n          loadingChildren={downloaded ? \"Installing...\" : \"Downloading...\"}\n          onClick={async () => {\n            await emit<UpdateResponse>(replyEventId, { type: \"action\", action: \"install\" });\n          }}\n        >\n          {downloaded ? \"Install Now\" : \"Download and Install\"}\n        </ButtonInfiniteLoading>\n        <Button\n          size=\"xs\"\n          color=\"info\"\n          variant=\"border\"\n          rightSlot={<Icon icon=\"external_link\" />}\n          onClick={async () => {\n            await openUrl(`https://yaak.app/changelog/${version}`);\n          }}\n        >\n          What&apos;s New\n        </Button>\n      </HStack>\n    ),\n  });\n}\n\nfunction showPluginUpdatesToast(updateInfo: PluginUpdateNotification) {\n  const PLUGIN_UPDATE_TOAST_ID = \"plugin-updates\";\n  const count = updateInfo.updateCount;\n  const pluginNames = updateInfo.plugins.map((p: { name: string }) => p.name);\n\n  showToast({\n    id: PLUGIN_UPDATE_TOAST_ID,\n    color: \"info\",\n    timeout: null,\n    message: (\n      <VStack>\n        <h2 className=\"font-semibold\">\n          {count === 1 ? \"1 plugin update\" : `${count} plugin updates`} available\n        </h2>\n        <p className=\"text-text-subtle text-sm\">\n          {count === 1\n            ? pluginNames[0]\n            : `${pluginNames.slice(0, 2).join(\", \")}${count > 2 ? `, and ${count - 2} more` : \"\"}`}\n        </p>\n      </VStack>\n    ),\n    action: ({ hide }) => (\n      <HStack space={1.5}>\n        <ButtonInfiniteLoading\n          size=\"xs\"\n          color=\"info\"\n          className=\"min-w-[5rem]\"\n          loadingChildren=\"Updating...\"\n          onClick={async () => {\n            const updated = await updateAllPlugins();\n            hide();\n            if (updated.length > 0) {\n              showToast({\n                color: \"success\",\n                message: `Successfully updated ${updated.length} plugin${updated.length === 1 ? \"\" : \"s\"}`,\n              });\n            }\n          }}\n        >\n          Update All\n        </ButtonInfiniteLoading>\n        <Button\n          size=\"xs\"\n          color=\"info\"\n          variant=\"border\"\n          onClick={() => {\n            hide();\n            openSettings.mutate(\"plugins:installed\");\n          }}\n        >\n          View Updates\n        </Button>\n      </HStack>\n    ),\n  });\n}\n\nfunction showNotificationToast(n: YaakNotification) {\n  const actionUrl = n.action?.url;\n  const actionLabel = n.action?.label;\n  showToast({\n    id: n.id,\n    timeout: n.timeout ?? null,\n    color: stringToColor(n.color) ?? undefined,\n    message: (\n      <VStack>\n        {n.title && <h2 className=\"font-semibold\">{n.title}</h2>}\n        <p className=\"text-text-subtle text-sm\">{n.message}</p>\n      </VStack>\n    ),\n    onClose: () => {\n      invokeCmd(\"cmd_dismiss_notification\", { notificationId: n.id }).catch(console.error);\n    },\n    action: ({ hide }) => {\n      return actionLabel && actionUrl ? (\n        <Button\n          size=\"xs\"\n          color={stringToColor(n.color) ?? undefined}\n          className=\"mr-auto min-w-[5rem]\"\n          rightSlot={<Icon icon=\"external_link\" />}\n          onClick={() => {\n            hide();\n            return openUrl(actionUrl);\n          }}\n        >\n          {actionLabel}\n        </Button>\n      ) : null;\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/lib/jotai.ts",
    "content": "import { createStore } from \"jotai\";\n\nexport const jotaiStore = createStore();\n"
  },
  {
    "path": "src-web/lib/jsonComments.ts",
    "content": "/**\n * Simple heuristic to detect if a string likely contains JSON/JSONC comments.\n * Checks for // and /* patterns that are NOT inside double-quoted strings.\n * Used for UI hints only — doesn't need to be perfect.\n */\nexport function textLikelyContainsJsonComments(text: string): boolean {\n  let inString = false;\n  for (let i = 0; i < text.length; i++) {\n    const ch = text[i];\n    if (inString) {\n      if (ch === '\"') {\n        inString = false;\n      } else if (ch === \"\\\\\") {\n        i++; // skip escaped char\n      }\n      continue;\n    }\n    if (ch === '\"') {\n      inString = true;\n      continue;\n    }\n    if (ch === \"/\" && i + 1 < text.length) {\n      const next = text[i + 1];\n      if (next === \"/\" || next === \"*\") {\n        return true;\n      }\n    }\n  }\n  return false;\n}\n"
  },
  {
    "path": "src-web/lib/keyValueStore.ts",
    "content": "import type { KeyValue } from \"@yaakapp-internal/models\";\nimport { createGlobalModel, keyValuesAtom, patchModel } from \"@yaakapp-internal/models\";\nimport { jotaiStore } from \"./jotai\";\n\nexport async function setKeyValue<T>({\n  namespace = \"global\",\n  key: keyOrKeys,\n  value: rawValue,\n}: {\n  namespace?: string;\n  key: string | string[];\n  value: T;\n}): Promise<void> {\n  const kv = getKeyValueRaw({ namespace, key: keyOrKeys });\n  const key = buildKeyValueKey(keyOrKeys);\n  const value = JSON.stringify(rawValue);\n\n  if (kv) {\n    await patchModel(kv, { namespace, key, value });\n  } else {\n    await createGlobalModel({ model: \"key_value\", namespace, key, value });\n  }\n}\n\nexport function getKeyValueRaw({\n  namespace = \"global\",\n  key: keyOrKeys,\n}: {\n  namespace?: string;\n  key: string | string[];\n}) {\n  const key = buildKeyValueKey(keyOrKeys);\n  const keyValues = jotaiStore.get(keyValuesAtom);\n  const kv = keyValues.find((kv) => kv.namespace === namespace && kv?.key === key);\n  return kv ?? null;\n}\n\nexport function getKeyValue<T>({\n  namespace = \"global\",\n  key,\n  fallback,\n}: {\n  namespace?: string;\n  key: string | string[];\n  fallback: T;\n}) {\n  const kv = getKeyValueRaw({ namespace, key });\n  return extractKeyValueOrFallback(kv, fallback);\n}\n\nexport function extractKeyValue<T>(kv: KeyValue | null): T | undefined {\n  if (kv === null) return undefined;\n  try {\n    return JSON.parse(kv.value) as T;\n  } catch (err) {\n    console.log(\"Failed to parse kv value\", kv.value, err);\n    return undefined;\n  }\n}\n\nexport function extractKeyValueOrFallback<T>(kv: KeyValue | null, fallback: T): T {\n  const v = extractKeyValue<T>(kv);\n  if (v === undefined) return fallback;\n  return v;\n}\n\nexport function buildKeyValueKey(key: string | string[]): string {\n  if (typeof key === \"string\") return key;\n  return key.join(\"::\");\n}\n"
  },
  {
    "path": "src-web/lib/markdown.ts",
    "content": "import rehypeStringify from \"rehype-stringify\";\nimport remarkGfm from \"remark-gfm\";\nimport remarkParse from \"remark-parse\";\nimport remarkRehype from \"remark-rehype\";\nimport { unified } from \"unified\";\n\nconst renderer = unified()\n  .use(remarkParse)\n  .use(remarkGfm)\n  .use(remarkRehype, {\n    // handlers: {\n    //   link: (state, node, parent) => {\n    //     return node;\n    //   },\n    // },\n  })\n  .use(rehypeStringify);\n\nexport async function renderMarkdown(md: string): Promise<string> {\n  try {\n    const r = await renderer.process(md);\n    return r.toString();\n  } catch (err) {\n    console.log(\"FAILED TO RENDER MARKDOWN\", err);\n    return \"error\";\n  }\n}\n"
  },
  {
    "path": "src-web/lib/minPromiseMillis.ts",
    "content": "import { sleep } from \"./sleep\";\n\n/** Ensures a promise takes at least a certain number of milliseconds to resolve */\nexport async function minPromiseMillis<T>(promise: Promise<T>, millis = 300) {\n  const start = Date.now();\n  let result: T;\n\n  try {\n    result = await promise;\n  } catch (e) {\n    const delayFor = millis - (Date.now() - start);\n    await sleep(delayFor);\n    throw e;\n  }\n\n  const delayFor = millis - (Date.now() - start);\n  await sleep(delayFor);\n  return result;\n}\n"
  },
  {
    "path": "src-web/lib/model_util.test.ts",
    "content": "import type { HttpResponseEvent } from \"@yaakapp-internal/models\";\nimport { describe, expect, test } from \"vite-plus/test\";\nimport { getCookieCounts } from \"./model_util\";\n\nfunction makeEvent(type: string, name: string, value: string): HttpResponseEvent {\n  return {\n    id: \"test\",\n    model: \"http_response_event\",\n    responseId: \"resp\",\n    workspaceId: \"ws\",\n    createdAt: new Date().toISOString(),\n    updatedAt: new Date().toISOString(),\n    event: { type, name, value } as HttpResponseEvent[\"event\"],\n  };\n}\n\ndescribe(\"getCookieCounts\", () => {\n  test(\"returns zeros for undefined events\", () => {\n    expect(getCookieCounts(undefined)).toEqual({ sent: 0, received: 0 });\n  });\n\n  test(\"returns zeros for empty events\", () => {\n    expect(getCookieCounts([])).toEqual({ sent: 0, received: 0 });\n  });\n\n  test(\"counts single sent cookie\", () => {\n    const events = [makeEvent(\"header_up\", \"Cookie\", \"session=abc123\")];\n    expect(getCookieCounts(events)).toEqual({ sent: 1, received: 0 });\n  });\n\n  test(\"counts multiple sent cookies in one header\", () => {\n    const events = [makeEvent(\"header_up\", \"Cookie\", \"a=1; b=2; c=3\")];\n    expect(getCookieCounts(events)).toEqual({ sent: 3, received: 0 });\n  });\n\n  test(\"counts single received cookie\", () => {\n    const events = [makeEvent(\"header_down\", \"Set-Cookie\", \"session=abc123; Path=/\")];\n    expect(getCookieCounts(events)).toEqual({ sent: 0, received: 1 });\n  });\n\n  test(\"counts multiple received cookies from multiple headers\", () => {\n    const events = [\n      makeEvent(\"header_down\", \"Set-Cookie\", \"a=1; Path=/\"),\n      makeEvent(\"header_down\", \"Set-Cookie\", \"b=2; HttpOnly\"),\n      makeEvent(\"header_down\", \"Set-Cookie\", \"c=3; Secure\"),\n    ];\n    expect(getCookieCounts(events)).toEqual({ sent: 0, received: 3 });\n  });\n\n  test(\"deduplicates sent cookies by name\", () => {\n    const events = [\n      makeEvent(\"header_up\", \"Cookie\", \"session=old\"),\n      makeEvent(\"header_up\", \"Cookie\", \"session=new\"),\n    ];\n    expect(getCookieCounts(events)).toEqual({ sent: 1, received: 0 });\n  });\n\n  test(\"deduplicates received cookies by name\", () => {\n    const events = [\n      makeEvent(\"header_down\", \"Set-Cookie\", \"token=abc; Path=/\"),\n      makeEvent(\"header_down\", \"Set-Cookie\", \"token=xyz; Path=/\"),\n    ];\n    expect(getCookieCounts(events)).toEqual({ sent: 0, received: 1 });\n  });\n\n  test(\"counts both sent and received cookies\", () => {\n    const events = [\n      makeEvent(\"header_up\", \"Cookie\", \"a=1; b=2; c=3\"),\n      makeEvent(\"header_down\", \"Set-Cookie\", \"x=10; Path=/\"),\n      makeEvent(\"header_down\", \"Set-Cookie\", \"y=20; Path=/\"),\n      makeEvent(\"header_down\", \"Set-Cookie\", \"z=30; Path=/\"),\n    ];\n    expect(getCookieCounts(events)).toEqual({ sent: 3, received: 3 });\n  });\n\n  test(\"ignores non-cookie headers\", () => {\n    const events = [\n      makeEvent(\"header_up\", \"Content-Type\", \"application/json\"),\n      makeEvent(\"header_down\", \"Content-Length\", \"123\"),\n    ];\n    expect(getCookieCounts(events)).toEqual({ sent: 0, received: 0 });\n  });\n\n  test(\"handles case-insensitive header names\", () => {\n    const events = [\n      makeEvent(\"header_up\", \"COOKIE\", \"a=1\"),\n      makeEvent(\"header_down\", \"SET-COOKIE\", \"b=2; Path=/\"),\n    ];\n    expect(getCookieCounts(events)).toEqual({ sent: 1, received: 1 });\n  });\n});\n"
  },
  {
    "path": "src-web/lib/model_util.ts",
    "content": "import type {\n  AnyModel,\n  Cookie,\n  Environment,\n  HttpResponseEvent,\n  HttpResponseHeader,\n} from \"@yaakapp-internal/models\";\nimport { getMimeTypeFromContentType } from \"./contentType\";\n\nexport const BODY_TYPE_NONE = null;\nexport const BODY_TYPE_GRAPHQL = \"graphql\";\nexport const BODY_TYPE_JSON = \"application/json\";\nexport const BODY_TYPE_BINARY = \"binary\";\nexport const BODY_TYPE_OTHER = \"other\";\nexport const BODY_TYPE_FORM_URLENCODED = \"application/x-www-form-urlencoded\";\nexport const BODY_TYPE_FORM_MULTIPART = \"multipart/form-data\";\nexport const BODY_TYPE_XML = \"text/xml\";\n\nexport function cookieDomain(cookie: Cookie): string {\n  if (cookie.domain === \"NotPresent\" || cookie.domain === \"Empty\") {\n    return \"n/a\";\n  }\n  if (\"HostOnly\" in cookie.domain) {\n    return cookie.domain.HostOnly;\n  }\n  if (\"Suffix\" in cookie.domain) {\n    return cookie.domain.Suffix;\n  }\n  return \"unknown\";\n}\n\nexport function modelsEq(a: AnyModel, b: AnyModel) {\n  if (a.model !== b.model) {\n    return false;\n  }\n  if (a.model === \"key_value\" && b.model === \"key_value\") {\n    return a.key === b.key && a.namespace === b.namespace;\n  }\n  if (\"id\" in a && \"id\" in b) {\n    return a.id === b.id;\n  }\n  return false;\n}\n\nexport function getContentTypeFromHeaders(headers: HttpResponseHeader[] | null): string | null {\n  return headers?.find((h) => h.name.toLowerCase() === \"content-type\")?.value ?? null;\n}\n\nexport function getCharsetFromContentType(headers: HttpResponseHeader[]): string | null {\n  const contentType = getContentTypeFromHeaders(headers);\n  if (contentType == null) return null;\n\n  const mimeType = getMimeTypeFromContentType(contentType);\n  return mimeType.parameters.get(\"charset\") ?? null;\n}\n\nexport function isBaseEnvironment(environment: Environment): boolean {\n  return environment.parentModel === \"workspace\";\n}\n\nexport function isSubEnvironment(environment: Environment): boolean {\n  return environment.parentModel === \"environment\";\n}\n\nexport function isFolderEnvironment(environment: Environment): boolean {\n  return environment.parentModel === \"folder\";\n}\n\nexport function getCookieCounts(events: HttpResponseEvent[] | undefined): {\n  sent: number;\n  received: number;\n} {\n  if (!events) return { sent: 0, received: 0 };\n\n  // Use Sets to deduplicate by cookie name\n  const sentNames = new Set<string>();\n  const receivedNames = new Set<string>();\n\n  for (const event of events) {\n    const e = event.event;\n    if (e.type === \"header_up\" && e.name.toLowerCase() === \"cookie\") {\n      // Parse \"Cookie: name=value; name2=value2\" format\n      for (const pair of e.value.split(\";\")) {\n        const name = pair.split(\"=\")[0]?.trim();\n        if (name) sentNames.add(name);\n      }\n    } else if (e.type === \"header_down\" && e.name.toLowerCase() === \"set-cookie\") {\n      // Parse \"Set-Cookie: name=value; ...\" - first part before ; is name=value\n      const name = e.value.split(\";\")[0]?.split(\"=\")[0]?.trim();\n      if (name) receivedNames.add(name);\n    }\n  }\n\n  return { sent: sentNames.size, received: receivedNames.size };\n}\n"
  },
  {
    "path": "src-web/lib/pluralize.ts",
    "content": "export function pluralize(word: string, count: number): string {\n  if (count === 1) {\n    return word;\n  }\n  return `${word}s`;\n}\n\nexport function pluralizeCount(\n  word: string,\n  count: number,\n  opt: { omitSingle?: boolean; noneWord?: string } = {},\n): string {\n  if (opt.omitSingle && count === 1) {\n    return word;\n  }\n  if (opt.noneWord && count === 0) {\n    return opt.noneWord;\n  }\n  return `${count} ${pluralize(word, count)}`;\n}\n"
  },
  {
    "path": "src-web/lib/prepareImportQuerystring.ts",
    "content": "import type { HttpUrlParameter } from \"@yaakapp-internal/models\";\nimport { generateId } from \"./generateId\";\n\nexport function prepareImportQuerystring(\n  url: string,\n): { url: string; urlParameters: HttpUrlParameter[] } | null {\n  const split = url.split(/\\?(.*)/s);\n  const baseUrl = split[0] ?? \"\";\n  const querystring = split[1] ?? \"\";\n\n  // No querystring in url\n  if (!querystring) {\n    return null;\n  }\n\n  const parsedParams = Array.from(new URLSearchParams(querystring).entries());\n  const urlParameters: HttpUrlParameter[] = parsedParams.map(([name, value]) => ({\n    name,\n    value,\n    enabled: true,\n    id: generateId(),\n  }));\n\n  return { url: baseUrl, urlParameters };\n}\n"
  },
  {
    "path": "src-web/lib/prompt-form.tsx",
    "content": "import type { FormInput, JsonPrimitive } from \"@yaakapp-internal/plugins\";\nimport type { DialogProps } from \"../components/core/Dialog\";\nimport type { PromptProps } from \"../components/core/Prompt\";\nimport { Prompt } from \"../components/core/Prompt\";\nimport { showDialog } from \"./dialog\";\n\ntype FormArgs = Pick<DialogProps, \"title\" | \"description\" | \"size\"> &\n  Omit<PromptProps, \"onClose\" | \"onCancel\" | \"onResult\"> & {\n    id: string;\n    onValuesChange?: (values: Record<string, JsonPrimitive>) => void;\n    onInputsUpdated?: (cb: (inputs: FormInput[]) => void) => void;\n  };\n\nexport async function showPromptForm({\n  id,\n  title,\n  description,\n  size,\n  onValuesChange,\n  onInputsUpdated,\n  ...props\n}: FormArgs) {\n  return new Promise((resolve: PromptProps[\"onResult\"]) => {\n    showDialog({\n      id,\n      title,\n      description,\n      hideX: true,\n      size: size ?? \"sm\",\n      disableBackdropClose: true, // Prevent accidental dismisses\n      onClose: () => {\n        // Click backdrop, close, or escape\n        resolve(null);\n      },\n      render: ({ hide }) =>\n        Prompt({\n          onCancel: () => {\n            // Click cancel button within dialog\n            resolve(null);\n            hide();\n          },\n          onResult: (v) => {\n            resolve(v);\n            hide();\n          },\n          onValuesChange,\n          onInputsUpdated,\n          ...props,\n        }),\n    });\n  });\n}\n"
  },
  {
    "path": "src-web/lib/prompt.ts",
    "content": "import type { FormInput, PromptTextRequest } from \"@yaakapp-internal/plugins\";\nimport type { ReactNode } from \"react\";\nimport type { DialogProps } from \"../components/core/Dialog\";\nimport { showPromptForm } from \"./prompt-form\";\n\ntype PromptProps = Omit<PromptTextRequest, \"id\" | \"title\" | \"description\"> & {\n  description?: ReactNode;\n  onCancel: () => void;\n  onResult: (value: string | null) => void;\n};\n\ntype PromptArgs = Pick<DialogProps, \"title\" | \"description\"> &\n  Omit<PromptProps, \"onClose\" | \"onCancel\" | \"onResult\"> & { id: string };\n\nexport async function showPrompt({\n  id,\n  title,\n  description,\n  cancelText,\n  confirmText,\n  required,\n  ...props\n}: PromptArgs) {\n  const inputs: FormInput[] = [\n    {\n      ...props,\n      optional: !required,\n      type: \"text\",\n      name: \"value\",\n    },\n  ];\n\n  const result = await showPromptForm({\n    id,\n    title,\n    description,\n    inputs,\n    cancelText,\n    confirmText,\n  });\n\n  if (result == null) return null; // Cancelled\n  if (typeof result.value === \"string\") return result.value;\n  return props.defaultValue ?? \"\";\n}\n"
  },
  {
    "path": "src-web/lib/queryClient.ts",
    "content": "import { QueryCache, QueryClient } from \"@tanstack/react-query\";\n\nexport const queryClient = new QueryClient({\n  queryCache: new QueryCache({\n    onError: (err, query) => {\n      console.log(\"Query client error\", { err, query });\n    },\n  }),\n  defaultOptions: {\n    queries: {\n      retry: false,\n      networkMode: \"always\",\n      refetchOnWindowFocus: true,\n      refetchOnReconnect: false,\n      refetchOnMount: false, // Don't refetch when a hook mounts\n    },\n  },\n});\n"
  },
  {
    "path": "src-web/lib/renameModelWithPrompt.tsx",
    "content": "import type { AnyModel } from \"@yaakapp-internal/models\";\nimport { patchModel } from \"@yaakapp-internal/models\";\nimport { InlineCode } from \"../components/core/InlineCode\";\nimport { showPrompt } from \"./prompt\";\n\nexport async function renameModelWithPrompt(model: Extract<AnyModel, { name: string }> | null) {\n  if (model == null) {\n    throw new Error(\"Tried to rename null model\");\n  }\n\n  const name = await showPrompt({\n    id: \"rename-request\",\n    title: \"Rename Request\",\n    required: false,\n    description:\n      model.name === \"\" ? (\n        \"Enter a new name\"\n      ) : (\n        <>\n          Enter a new name for <InlineCode>{model.name}</InlineCode>\n        </>\n      ),\n    label: \"Name\",\n    placeholder: \"New Name\",\n    defaultValue: model.name,\n    confirmText: \"Save\",\n  });\n\n  if (name == null) return;\n\n  await patchModel(model, { name });\n}\n"
  },
  {
    "path": "src-web/lib/resolvedModelName.ts",
    "content": "import type { AnyModel } from \"@yaakapp-internal/models\";\nimport { foldersAtom } from \"@yaakapp-internal/models\";\nimport { jotaiStore } from \"./jotai\";\n\nexport function resolvedModelName(r: AnyModel | null): string {\n  if (r == null) return \"\";\n\n  if (!(\"url\" in r) || r.model === \"plugin\") {\n    return \"name\" in r ? r.name : \"\";\n  }\n\n  // Return name if it has one\n  if (\"name\" in r && r.name) {\n    return r.name;\n  }\n\n  // Replace variable syntax with variable name\n  const withoutVariables = r.url.replace(/\\$\\{\\[\\s*([^\\]\\s]+)\\s*]}/g, \"$1\");\n  if (withoutVariables.trim() === \"\") {\n    return r.model === \"http_request\"\n      ? r.bodyType && r.bodyType === \"graphql\"\n        ? \"GraphQL Request\"\n        : \"HTTP Request\"\n      : r.model === \"websocket_request\"\n        ? \"WebSocket Request\"\n        : \"gRPC Request\";\n  }\n\n  // GRPC gets nice short names\n  if (r.model === \"grpc_request\" && r.service != null && r.method != null) {\n    const shortService = r.service.split(\".\").pop();\n    return `${shortService}/${r.method}`;\n  }\n\n  // Strip unnecessary protocol\n  const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\\/\\//, \"\");\n\n  return withoutProto;\n}\n\nexport function resolvedModelNameWithFolders(model: AnyModel | null): string {\n  return resolvedModelNameWithFoldersArray(model).join(\" / \");\n}\n\nexport function resolvedModelNameWithFoldersArray(model: AnyModel | null): string[] {\n  if (model == null) return [];\n  const folders = jotaiStore.get(foldersAtom) ?? [];\n\n  const getParents = (m: AnyModel, names: string[]) => {\n    let newNames = [...names, resolvedModelName(m)];\n    if (\"folderId\" in m) {\n      const parent = folders.find((f) => f.id === m.folderId);\n      if (parent) {\n        newNames = [...resolvedModelNameWithFoldersArray(parent), ...newNames];\n      }\n    }\n    return newNames;\n  };\n\n  return getParents(model, []);\n}\n"
  },
  {
    "path": "src-web/lib/responseBody.ts",
    "content": "import { readFile } from \"@tauri-apps/plugin-fs\";\nimport type { HttpResponse } from \"@yaakapp-internal/models\";\nimport type { FilterResponse } from \"@yaakapp-internal/plugins\";\nimport type { ServerSentEvent } from \"@yaakapp-internal/sse\";\nimport { invokeCmd } from \"./tauri\";\n\nexport async function getResponseBodyText({\n  response,\n  filter,\n}: {\n  response: HttpResponse;\n  filter: string | null;\n}): Promise<string | null> {\n  const result = await invokeCmd<FilterResponse>(\"cmd_http_response_body\", {\n    response,\n    filter,\n  });\n\n  if (result.error) {\n    throw new Error(result.error);\n  }\n\n  return result.content;\n}\n\nexport async function getResponseBodyEventSource(\n  response: HttpResponse,\n): Promise<ServerSentEvent[]> {\n  if (!response.bodyPath) return [];\n  return invokeCmd<ServerSentEvent[]>(\"cmd_get_sse_events\", {\n    filePath: response.bodyPath,\n  });\n}\n\nexport async function getResponseBodyBytes(\n  response: HttpResponse,\n): Promise<Uint8Array<ArrayBuffer> | null> {\n  if (!response.bodyPath) return null;\n  return readFile(response.bodyPath);\n}\n"
  },
  {
    "path": "src-web/lib/reveal.ts",
    "content": "import { type } from \"@tauri-apps/plugin-os\";\n\nconst os = type();\nexport const revealInFinderText =\n  os === \"macos\"\n    ? \"Reveal in Finder\"\n    : os === \"windows\"\n      ? \"Show in Explorer\"\n      : \"Show in File Manager\";\n"
  },
  {
    "path": "src-web/lib/router.ts",
    "content": "// Create a new router instance\nimport { createRouter } from \"@tanstack/react-router\";\nimport { routeTree } from \"../routeTree.gen\";\n\nexport const router = createRouter({ routeTree });\n\n// Register the router instance for type safety\ndeclare module \"@tanstack/react-router\" {\n  interface Register {\n    router: typeof router;\n  }\n}\n"
  },
  {
    "path": "src-web/lib/scopes.ts",
    "content": "export function isSidebarFocused() {\n  return document.activeElement?.closest(\".x-theme-sidebar\") != null;\n}\n"
  },
  {
    "path": "src-web/lib/sendEphemeralRequest.ts",
    "content": "import type { HttpRequest, HttpResponse } from \"@yaakapp-internal/models\";\nimport { getActiveCookieJar } from \"../hooks/useActiveCookieJar\";\nimport { invokeCmd } from \"./tauri\";\n\nexport async function sendEphemeralRequest(\n  request: HttpRequest,\n  environmentId: string | null,\n): Promise<HttpResponse> {\n  // Remove some things that we don't want to associate\n  const newRequest = { ...request };\n  return invokeCmd(\"cmd_send_ephemeral_request\", {\n    request: newRequest,\n    environmentId,\n    cookieJarId: getActiveCookieJar()?.id,\n  });\n}\n"
  },
  {
    "path": "src-web/lib/setWorkspaceSearchParams.ts",
    "content": "import type { Folder, GrpcRequest, WebsocketRequest, Workspace } from \"@yaakapp-internal/models\";\nimport type { HttpRequest } from \"@yaakapp-internal/sync\";\nimport { router } from \"./router.js\";\n\n/**\n * Setting search params using \"from\" on the global router instance in tanstack router does not\n * currently behave very well, so this is a wrapper function that gives a typesafe interface\n * for the same thing.\n */\nexport function setWorkspaceSearchParams(\n  search: Partial<{\n    cookie_jar_id: string | null;\n    environment_id: string | null;\n    request_id: string | null;\n    folder_id: string | null;\n  }>,\n) {\n  // oxlint-disable-next-line no-explicit-any\n  (router as any)\n    .navigate({\n      // oxlint-disable-next-line no-explicit-any\n      search: (prev: any) => {\n        // console.log('Navigating to', { prev, search });\n        const o = { ...prev, ...search };\n        for (const k of Object.keys(o)) {\n          if (o[k] == null) {\n            delete o[k];\n          }\n        }\n        return o;\n      },\n    })\n    .catch(console.error);\n}\n\nexport function navigateToRequestOrFolderOrWorkspace(\n  id: string,\n  model: (Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest)[\"model\"],\n) {\n  if (model === \"workspace\") {\n    setWorkspaceSearchParams({ request_id: null, folder_id: null });\n  } else if (model === \"folder\") {\n    setWorkspaceSearchParams({ request_id: null, folder_id: id });\n  } else {\n    setWorkspaceSearchParams({ request_id: id, folder_id: null });\n  }\n}\n"
  },
  {
    "path": "src-web/lib/settings.ts",
    "content": "import { invoke } from \"@tauri-apps/api/core\";\nimport type { Settings } from \"@yaakapp-internal/models\";\n\nexport function getSettings(): Promise<Settings> {\n  return invoke<Settings>(\"models_get_settings\");\n}\n"
  },
  {
    "path": "src-web/lib/setupOrConfigureEncryption.tsx",
    "content": "import { VStack } from \"../components/core/Stacks\";\nimport { WorkspaceEncryptionSetting } from \"../components/WorkspaceEncryptionSetting\";\nimport { activeWorkspaceMetaAtom } from \"../hooks/useActiveWorkspace\";\nimport { showDialog } from \"./dialog\";\nimport { jotaiStore } from \"./jotai\";\n\nexport function setupOrConfigureEncryption() {\n  setupOrConfigure();\n}\n\nexport function withEncryptionEnabled(callback?: () => void) {\n  const workspaceMeta = jotaiStore.get(activeWorkspaceMetaAtom);\n  if (workspaceMeta?.encryptionKey != null) {\n    callback?.(); // Already set up\n    return;\n  }\n\n  setupOrConfigure(callback);\n}\n\nfunction setupOrConfigure(onEnable?: () => void) {\n  showDialog({\n    id: \"workspace-encryption\",\n    title: \"Workspace Encryption\",\n    size: \"md\",\n    render: ({ hide }) => (\n      <VStack space={3} className=\"pb-2\" alignItems=\"end\">\n        <WorkspaceEncryptionSetting expanded onDone={hide} onEnabledEncryption={onEnable} />\n      </VStack>\n    ),\n  });\n}\n"
  },
  {
    "path": "src-web/lib/showColorPicker.tsx",
    "content": "import type { Environment } from \"@yaakapp-internal/models\";\nimport { patchModel } from \"@yaakapp-internal/models\";\nimport { EnvironmentColorPicker } from \"../components/EnvironmentColorPicker\";\nimport { showDialog } from \"./dialog\";\n\nexport function showColorPicker(environment: Environment) {\n  showDialog({\n    title: \"Environment Color\",\n    id: \"color-picker\",\n    size: \"sm\",\n    render: ({ hide }) => {\n      return (\n        <EnvironmentColorPicker\n          color={environment.color}\n          onChange={async (color) => {\n            await patchModel(environment, { color });\n            hide();\n          }}\n        />\n      );\n    },\n  });\n}\n"
  },
  {
    "path": "src-web/lib/sleep.ts",
    "content": "export async function sleep(millis: number) {\n  await new Promise((resolve) => setTimeout(resolve, millis));\n}\n"
  },
  {
    "path": "src-web/lib/tauri.ts",
    "content": "import type { InvokeArgs } from \"@tauri-apps/api/core\";\nimport { invoke } from \"@tauri-apps/api/core\";\n\ntype TauriCmd =\n  | \"cmd_call_grpc_request_action\"\n  | \"cmd_call_http_authentication_action\"\n  | \"cmd_call_http_request_action\"\n  | \"cmd_call_websocket_request_action\"\n  | \"cmd_call_workspace_action\"\n  | \"cmd_call_folder_action\"\n  | \"cmd_check_for_updates\"\n  | \"cmd_curl_to_request\"\n  | \"cmd_decrypt_template\"\n  | \"cmd_default_headers\"\n  | \"cmd_delete_all_grpc_connections\"\n  | \"cmd_delete_all_http_responses\"\n  | \"cmd_delete_send_history\"\n  | \"cmd_dismiss_notification\"\n  | \"cmd_export_data\"\n  | \"cmd_format_graphql\"\n  | \"cmd_format_json\"\n  | \"cmd_get_http_authentication_config\"\n  | \"cmd_get_http_authentication_summaries\"\n  | \"cmd_get_http_response_events\"\n  | \"cmd_get_sse_events\"\n  | \"cmd_get_themes\"\n  | \"cmd_get_workspace_meta\"\n  | \"cmd_git_add_credential\"\n  | \"cmd_git_clone\"\n  | \"cmd_grpc_go\"\n  | \"cmd_grpc_reflect\"\n  | \"cmd_grpc_request_actions\"\n  | \"cmd_http_request_actions\"\n  | \"cmd_websocket_request_actions\"\n  | \"cmd_workspace_actions\"\n  | \"cmd_folder_actions\"\n  | \"cmd_http_request_body\"\n  | \"cmd_http_response_body\"\n  | \"cmd_import_data\"\n  | \"cmd_metadata\"\n  | \"cmd_restart\"\n  | \"cmd_new_child_window\"\n  | \"cmd_new_main_window\"\n  | \"cmd_plugin_info\"\n  | \"cmd_plugin_init_errors\"\n  | \"cmd_reload_plugins\"\n  | \"cmd_render_template\"\n  | \"cmd_save_response\"\n  | \"cmd_secure_template\"\n  | \"cmd_send_ephemeral_request\"\n  | \"cmd_send_http_request\"\n  | \"cmd_template_function_summaries\"\n  | \"cmd_template_function_config\"\n  | \"cmd_template_tokens_to_string\";\n\nexport async function invokeCmd<T>(cmd: TauriCmd, args?: InvokeArgs): Promise<T> {\n  // console.log('RUN COMMAND', cmd, args);\n  try {\n    return await invoke(cmd, args);\n  } catch (err) {\n    console.warn(\"Tauri command error\", cmd, err);\n    throw err;\n  }\n}\n"
  },
  {
    "path": "src-web/lib/theme/appearance.ts",
    "content": "import { getCurrentWebviewWindow } from \"@tauri-apps/api/webviewWindow\";\nimport { fireAndForget } from \"../fireAndForget\";\n\nexport type Appearance = \"light\" | \"dark\";\n\nexport function getCSSAppearance(): Appearance {\n  return window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\";\n}\n\nexport async function getWindowAppearance(): Promise<Appearance> {\n  const a = await getCurrentWebviewWindow().theme();\n  return a ?? getCSSAppearance();\n}\n\n/**\n * Subscribe to appearance (dark/light) changes. Note, we use Tauri Window appearance instead of\n * CSS appearance because CSS won't fire the way we handle window theme management.\n */\nexport function subscribeToWindowAppearanceChange(\n  cb: (appearance: Appearance) => void,\n): () => void {\n  const container = {\n    unsubscribe: () => {},\n  };\n\n  fireAndForget(\n    getCurrentWebviewWindow()\n      .onThemeChanged((t) => {\n        cb(t.payload);\n      })\n      .then((l) => {\n        container.unsubscribe = l;\n      }),\n  );\n\n  return () => container.unsubscribe();\n}\n\nexport function resolveAppearance(\n  preferredAppearance: Appearance,\n  appearanceSetting: string,\n): Appearance {\n  const appearance = appearanceSetting === \"system\" ? preferredAppearance : appearanceSetting;\n  return appearance === \"dark\" ? \"dark\" : \"light\";\n}\n\nexport function subscribeToPreferredAppearance(cb: (a: Appearance) => void) {\n  cb(getCSSAppearance());\n  fireAndForget(getWindowAppearance().then(cb));\n  subscribeToWindowAppearanceChange(cb);\n}\n"
  },
  {
    "path": "src-web/lib/theme/themes.ts",
    "content": "import type { GetThemesResponse } from \"@yaakapp-internal/plugins\";\nimport { invokeCmd } from \"../tauri\";\nimport type { Appearance } from \"./appearance\";\nimport { resolveAppearance } from \"./appearance\";\n\nexport async function getThemes() {\n  const themes = (await invokeCmd<GetThemesResponse[]>(\"cmd_get_themes\")).flatMap((t) => t.themes);\n  themes.sort((a, b) => a.label.localeCompare(b.label));\n  // Remove duplicates, in case multiple plugins provide the same theme\n  const uniqueThemes = Array.from(new Map(themes.map((t) => [t.id, t])).values());\n  return { themes: [yaakDark, yaakLight, ...uniqueThemes] };\n}\n\nexport async function getResolvedTheme(\n  preferredAppearance: Appearance,\n  appearanceSetting: string,\n  themeLight: string,\n  themeDark: string,\n) {\n  const appearance = resolveAppearance(preferredAppearance, appearanceSetting);\n  const { themes } = await getThemes();\n\n  const darkThemes = themes.filter((t) => t.dark);\n  const lightThemes = themes.filter((t) => !t.dark);\n\n  const dark = darkThemes.find((t) => t.id === themeDark) ?? darkThemes[0] ?? yaakDark;\n  const light = lightThemes.find((t) => t.id === themeLight) ?? lightThemes[0] ?? yaakLight;\n\n  const active = appearance === \"dark\" ? dark : light;\n\n  return { dark, light, active };\n}\n\nconst yaakDark = {\n  id: \"yaak-dark\",\n  label: \"Yaak\",\n  dark: true,\n  base: {\n    surface: \"hsl(244,23%,14%)\",\n    surfaceHighlight: \"hsl(244,23%,20%)\",\n    text: \"hsl(245,23%,85%)\",\n    textSubtle: \"hsl(245,18%,58%)\",\n    textSubtlest: \"hsl(245,18%,45%)\",\n    border: \"hsl(244,23%,25%)\",\n    primary: \"hsl(266,100%,79%)\",\n    secondary: \"hsl(245,23%,60%)\",\n    info: \"hsl(206,100%,63%)\",\n    success: \"hsl(150,99%,44%)\",\n    notice: \"hsl(48,80%,63%)\",\n    warning: \"hsl(28,100%,61%)\",\n    danger: \"hsl(342,90%,68%)\",\n  },\n  components: {\n    button: {\n      primary: \"hsl(266,100%,71.1%)\",\n      secondary: \"hsl(244,23%,54%)\",\n      info: \"hsl(206,100%,56.7%)\",\n      success: \"hsl(150,99%,37.4%)\",\n      notice: \"hsl(48,80%,50.4%)\",\n      warning: \"hsl(28,100%,54.9%)\",\n      danger: \"hsl(342,90%,61.2%)\",\n    },\n    dialog: {\n      border: \"hsl(244,23%,24%)\",\n    },\n    sidebar: {\n      surface: \"hsl(243,23%,16%)\",\n      border: \"hsl(244,23%,22%)\",\n    },\n    responsePane: {\n      surface: \"hsl(243,23%,16%)\",\n      border: \"hsl(246,23%,23%)\",\n    },\n    appHeader: {\n      surface: \"hsl(244,23%,12%)\",\n      border: \"hsl(244,23%,21%)\",\n    },\n  },\n};\n\nconst yaakLight = {\n  id: \"yaak-light\",\n  label: \"Yaak\",\n  dark: false,\n  base: {\n    surface: \"hsl(0,0%,100%)\",\n    surfaceHighlight: \"hsl(218,24%,87%)\",\n    text: \"hsl(217,24%,10%)\",\n    textSubtle: \"hsl(217,24%,40%)\",\n    textSubtlest: \"hsl(217,24%,58%)\",\n    border: \"hsl(217,22%,90%)\",\n    primary: \"hsl(266,100%,60%)\",\n    secondary: \"hsl(220,24%,50%)\",\n    info: \"hsl(206,100%,40%)\",\n    success: \"hsl(139,66%,34%)\",\n    notice: \"hsl(45,100%,34%)\",\n    warning: \"hsl(30,100%,36%)\",\n    danger: \"hsl(335,75%,48%)\",\n  },\n  components: {\n    sidebar: {\n      surface: \"hsl(220,20%,98%)\",\n      border: \"hsl(217,22%,88%)\",\n      surfaceHighlight: \"hsl(217,25%,90%)\",\n    },\n  },\n};\n\nexport const defaultDarkTheme = yaakDark;\nexport const defaultLightTheme = yaakLight;\n"
  },
  {
    "path": "src-web/lib/theme/window.ts",
    "content": "import type { Theme, ThemeComponentColors } from \"@yaakapp-internal/plugins\";\nimport { defaultDarkTheme, defaultLightTheme } from \"./themes\";\nimport { YaakColor } from \"./yaakColor\";\n\nexport type YaakColors = {\n  surface: YaakColor;\n  surfaceHighlight?: YaakColor;\n  surfaceActive?: YaakColor;\n\n  text: YaakColor;\n  textSubtle?: YaakColor;\n  textSubtlest?: YaakColor;\n\n  border?: YaakColor;\n  borderSubtle?: YaakColor;\n  borderFocus?: YaakColor;\n\n  shadow?: YaakColor;\n  backdrop?: YaakColor;\n  selection?: YaakColor;\n\n  primary?: YaakColor;\n  secondary?: YaakColor;\n  info?: YaakColor;\n  success?: YaakColor;\n  notice?: YaakColor;\n  warning?: YaakColor;\n  danger?: YaakColor;\n};\n\nexport type YaakTheme = {\n  id: string;\n  name: string;\n  base: YaakColors;\n  components?: Partial<{\n    dialog: Partial<YaakColors>;\n    menu: Partial<YaakColors>;\n    toast: Partial<YaakColors>;\n    sidebar: Partial<YaakColors>;\n    responsePane: Partial<YaakColors>;\n    appHeader: Partial<YaakColors>;\n    button: Partial<YaakColors>;\n    banner: Partial<YaakColors>;\n    templateTag: Partial<YaakColors>;\n    urlBar: Partial<YaakColors>;\n    editor: Partial<YaakColors>;\n    input: Partial<YaakColors>;\n  }>;\n};\n\nexport type YaakColorKey = keyof ThemeComponentColors;\n\ntype ComponentName = keyof NonNullable<YaakTheme[\"components\"]>;\n\ntype CSSVariables = Record<YaakColorKey, string | undefined>;\n\nfunction themeVariables(\n  theme: Theme,\n  component?: ComponentName,\n  base?: CSSVariables,\n): CSSVariables | null {\n  const cmp =\n    component == null\n      ? theme.base\n      : (theme.components?.[component] ?? ({} as ThemeComponentColors));\n  const c = (s: string | undefined) => yc(theme, s);\n  const vars: CSSVariables = {\n    surface: cmp.surface,\n    surfaceHighlight: cmp.surfaceHighlight ?? c(cmp.surface)?.lift(0.06).css(),\n    surfaceActive: cmp.surfaceActive ?? c(cmp.primary)?.lower(0.2).translucify(0.8).css(),\n    backdrop: cmp.backdrop ?? c(cmp.surface)?.lower(0.2).translucify(0.2).css(),\n    selection: cmp.selection ?? c(cmp.primary)?.lower(0.1).translucify(0.7).css(),\n    border: cmp.border ?? c(cmp.surface)?.lift(0.11)?.css(),\n    borderSubtle: cmp.borderSubtle ?? c(cmp.border)?.lower(0.06)?.css(),\n    borderFocus: c(cmp.info)?.translucify(0.5)?.css(),\n    text: cmp.text,\n    textSubtle: cmp.textSubtle ?? c(cmp.text)?.lower(0.2)?.css(),\n    textSubtlest: cmp.textSubtlest ?? c(cmp.text)?.lower(0.3)?.css(),\n    shadow:\n      cmp.shadow ??\n      YaakColor.black()\n        .translucify(theme.dark ? 0.7 : 0.93)\n        .css(),\n    primary: cmp.primary,\n    secondary: cmp.secondary,\n    info: cmp.info,\n    success: cmp.success,\n    notice: cmp.notice,\n    warning: cmp.warning,\n    danger: cmp.danger,\n  };\n\n  // Extend with base\n  for (const [k, v] of Object.entries(vars)) {\n    if (!v && base?.[k as YaakColorKey]) {\n      vars[k as YaakColorKey] = base[k as YaakColorKey];\n    }\n  }\n\n  return vars;\n}\n\nfunction templateTagColorVariables(color: YaakColor | null): Partial<CSSVariables> {\n  if (color == null) return {};\n\n  return {\n    text: color.lift(0.7).css(),\n    textSubtle: color.lift(0.4).css(),\n    textSubtlest: color.css(),\n    surface: color.lower(0.2).translucify(0.8).css(),\n    border: color.translucify(0.6).css(),\n    borderSubtle: color.translucify(0.8).css(),\n    surfaceHighlight: color.lower(0.1).translucify(0.7).css(),\n  };\n}\n\nfunction toastColorVariables(color: YaakColor | null): Partial<CSSVariables> {\n  if (color == null) return {};\n\n  return {\n    text: color.lift(0.8).css(),\n    textSubtle: color.lift(0.8).translucify(0.3).css(),\n    surface: color.translucify(0.9).css(),\n    surfaceHighlight: color.translucify(0.8).css(),\n    border: color.lift(0.3).translucify(0.6).css(),\n  };\n}\n\nfunction bannerColorVariables(color: YaakColor | null): Partial<CSSVariables> {\n  if (color == null) return {};\n\n  return {\n    text: color.lift(0.8).css(),\n    textSubtle: color.translucify(0.3).css(),\n    textSubtlest: color.translucify(0.6).css(),\n    surface: color.translucify(0.95).css(),\n    border: color.lift(0.3).translucify(0.8).css(),\n  };\n}\n\nfunction _inputCSS(color: YaakColor | null): Partial<CSSVariables> {\n  if (color == null) return {};\n\n  const theme: Partial<ThemeComponentColors> = {\n    border: color.css(),\n  };\n\n  return theme;\n}\n\nfunction buttonSolidColorVariables(\n  color: YaakColor | null,\n  isDefault = false,\n): Partial<CSSVariables> {\n  if (color == null) return {};\n\n  const theme: Partial<ThemeComponentColors> = {\n    text: \"white\",\n    surface: color.lower(0.3).css(),\n    surfaceHighlight: color.lower(0.1).css(),\n    border: color.css(),\n  };\n\n  if (isDefault) {\n    theme.text = undefined; // Inherit from parent\n    theme.surface = undefined; // Inherit from parent\n    theme.surfaceHighlight = color.lift(0.08).css();\n  }\n\n  return theme;\n}\n\nfunction buttonBorderColorVariables(\n  color: YaakColor | null,\n  isDefault = false,\n): Partial<CSSVariables> {\n  if (color == null) return {};\n\n  const vars: Partial<CSSVariables> = {\n    text: color.lift(0.8).css(),\n    textSubtle: color.lift(0.55).css(),\n    textSubtlest: color.lift(0.4).translucify(0.6).css(),\n    surfaceHighlight: color.translucify(0.8).css(),\n    borderSubtle: color.translucify(0.5).css(),\n    border: color.translucify(0.3).css(),\n  };\n\n  if (isDefault) {\n    vars.borderSubtle = color.lift(0.28).css();\n    vars.border = color.lift(0.5).css();\n  }\n\n  return vars;\n}\n\nfunction variablesToCSS(\n  selector: string | null,\n  vars: Partial<CSSVariables> | null,\n): string | null {\n  if (vars == null) {\n    return null;\n  }\n\n  const css = Object.entries(vars ?? {})\n    .filter(([, value]) => value)\n    .map(([name, value]) => `--${name}: ${value};`)\n    .join(\"\\n\");\n\n  return selector == null ? css : `${selector} {\\n${indent(css)}\\n}`;\n}\n\nfunction componentCSS(theme: Theme, component: ComponentName): string | null {\n  if (theme.components == null) {\n    return null;\n  }\n\n  const themeVars = themeVariables(theme, component);\n  return variablesToCSS(`.x-theme-${component}`, themeVars);\n}\n\nfunction buttonCSS(\n  theme: Theme,\n  color: YaakColorKey,\n  colors?: ThemeComponentColors,\n): string | null {\n  const yaakColor = yc(theme, colors?.[color]);\n  if (yaakColor == null) {\n    return null;\n  }\n\n  return [\n    variablesToCSS(`.x-theme-button--solid--${color}`, buttonSolidColorVariables(yaakColor)),\n    variablesToCSS(`.x-theme-button--border--${color}`, buttonBorderColorVariables(yaakColor)),\n  ].join(\"\\n\\n\");\n}\n\nfunction bannerCSS(\n  theme: Theme,\n  color: YaakColorKey,\n  colors?: ThemeComponentColors,\n): string | null {\n  const yaakColor = yc(theme, colors?.[color]);\n  if (yaakColor == null) {\n    return null;\n  }\n\n  return [variablesToCSS(`.x-theme-banner--${color}`, bannerColorVariables(yaakColor))].join(\n    \"\\n\\n\",\n  );\n}\n\nfunction toastCSS(theme: Theme, color: YaakColorKey, colors?: ThemeComponentColors): string | null {\n  const yaakColor = yc(theme, colors?.[color]);\n  if (yaakColor == null) {\n    return null;\n  }\n\n  return [variablesToCSS(`.x-theme-toast--${color}`, toastColorVariables(yaakColor))].join(\"\\n\\n\");\n}\n\nfunction templateTagCSS(\n  theme: Theme,\n  color: YaakColorKey,\n  colors?: ThemeComponentColors,\n): string | null {\n  const yaakColor = yc(theme, colors?.[color]);\n  if (yaakColor == null) {\n    return null;\n  }\n\n  return [\n    variablesToCSS(`.x-theme-templateTag--${color}`, templateTagColorVariables(yaakColor)),\n  ].join(\"\\n\\n\");\n}\n\nexport function getThemeCSS(theme: Theme): string {\n  theme.components = theme.components ?? {};\n  // Toast defaults to menu styles\n  theme.components.toast = theme.components.toast ?? theme.components.menu ?? {};\n  const { components, id, label } = theme;\n  const colors = Object.keys(theme.base).reduce((prev, key) => {\n    // oxlint-disable-next-line no-accumulating-spread\n    return { ...prev, [key]: theme.base[key as YaakColorKey] };\n  }, {}) as ThemeComponentColors;\n\n  let themeCSS = \"\";\n  try {\n    const baseCss = variablesToCSS(null, themeVariables(theme));\n    themeCSS = [\n      baseCss,\n      ...Object.keys(components ?? {}).map((key) => componentCSS(theme, key as ComponentName)),\n      variablesToCSS(\n        \".x-theme-button--solid--default\",\n        buttonSolidColorVariables(yc(theme, theme.base.surface), true),\n      ),\n      variablesToCSS(\n        \".x-theme-button--border--default\",\n        buttonBorderColorVariables(yc(theme, theme.base.surface), true),\n      ),\n      ...Object.keys(colors ?? {}).map((key) =>\n        buttonCSS(theme, key as YaakColorKey, theme.components?.button ?? colors),\n      ),\n      ...Object.keys(colors ?? {}).map((key) =>\n        bannerCSS(theme, key as YaakColorKey, theme.components?.banner ?? colors),\n      ),\n      ...Object.keys(colors ?? {}).map((key) =>\n        toastCSS(theme, key as YaakColorKey, theme.components?.banner ?? colors),\n      ),\n      ...Object.keys(colors ?? {}).map((key) =>\n        templateTagCSS(theme, key as YaakColorKey, theme.components?.templateTag ?? colors),\n      ),\n    ].join(\"\\n\\n\");\n  } catch (err) {\n    console.error(\"Failed to generate CSS\", err);\n  }\n\n  return [`/* ${label} */`, `[data-theme=\"${id}\"] {`, indent(themeCSS), \"}\"].join(\"\\n\");\n}\n\nexport function addThemeStylesToDocument(rawTheme: Theme | null) {\n  if (rawTheme == null) {\n    console.error(\"Failed to add theme styles: theme is null\");\n    return;\n  }\n\n  const theme = completeTheme(rawTheme);\n  let styleEl = document.head.querySelector(\"style[data-theme]\");\n  if (!styleEl) {\n    styleEl = document.createElement(\"style\");\n    document.head.appendChild(styleEl);\n  }\n\n  styleEl.setAttribute(\"data-theme\", theme.id);\n  styleEl.setAttribute(\"data-updated-at\", new Date().toISOString());\n  styleEl.textContent = getThemeCSS(theme);\n}\n\nexport function setThemeOnDocument(theme: Theme | null) {\n  if (theme == null) {\n    console.error(\"Failed to set theme: theme is null\");\n    return;\n  }\n\n  document.documentElement.setAttribute(\"data-theme\", theme.id);\n}\n\nexport function indent(text: string, space = \"    \"): string {\n  return text\n    .split(\"\\n\")\n    .map((line) => space + line)\n    .join(\"\\n\");\n}\n\nfunction yc<T extends string | null | undefined>(\n  theme: Theme,\n  s: T,\n): T extends string ? YaakColor : null {\n  if (s == null) return null as never;\n  return new YaakColor(s, theme.dark ? \"dark\" : \"light\") as never;\n}\n\nexport function completeTheme(theme: Theme): Theme {\n  const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base;\n  const c = (s: string | null | undefined) => yc(theme, s);\n\n  theme.base.primary ??= fallback.primary;\n  theme.base.secondary ??= fallback.secondary;\n  theme.base.info ??= fallback.info;\n  theme.base.success ??= fallback.success;\n  theme.base.notice ??= fallback.notice;\n  theme.base.warning ??= fallback.warning;\n  theme.base.danger ??= fallback.danger;\n\n  theme.base.surface ??= fallback.surface;\n  theme.base.surfaceHighlight ??= c(theme.base.surface)?.lift(0.06)?.css();\n  theme.base.surfaceActive ??= c(theme.base.primary)?.lower(0.2).translucify(0.8).css();\n\n  theme.base.border ??= c(theme.base.surface)?.lift(0.12)?.css();\n  theme.base.borderSubtle ??= c(theme.base.border)?.lower(0.08)?.css();\n\n  theme.base.text ??= fallback.text;\n  theme.base.textSubtle ??= c(theme.base.text)?.lower(0.3)?.css();\n  theme.base.textSubtlest ??= c(theme.base.text)?.lower(0.5)?.css();\n\n  return theme;\n}\n"
  },
  {
    "path": "src-web/lib/theme/yaakColor.ts",
    "content": "import parseColor from \"parse-color\";\n\nexport class YaakColor {\n  private readonly appearance: \"dark\" | \"light\" = \"light\";\n\n  private hue = 0;\n  private saturation = 0;\n  private lightness = 0;\n  private alpha = 1;\n\n  constructor(cssColor: string, appearance: \"dark\" | \"light\" = \"light\") {\n    try {\n      this.set(cssColor);\n      this.appearance = appearance;\n    } catch (err) {\n      console.log(\"Failed to parse CSS color\", cssColor, err);\n    }\n  }\n\n  static transparent(): YaakColor {\n    return new YaakColor(\"rgb(0,0,0)\", \"light\").translucify(1);\n  }\n\n  static white(): YaakColor {\n    return new YaakColor(\"rgb(0,0,0)\", \"light\").lower(1);\n  }\n\n  static black(): YaakColor {\n    return new YaakColor(\"rgb(0,0,0)\", \"light\").lift(1);\n  }\n\n  set(cssColor: string): YaakColor {\n    let fixedCssColor = cssColor;\n    if (cssColor.startsWith(\"#\") && cssColor.length === 9) {\n      const [r, g, b, a] = hexToRgba(cssColor);\n      fixedCssColor = `rgba(${r},${g},${b},${a})`;\n    }\n    const { hsla } = parseColor(fixedCssColor);\n    this.hue = hsla[0];\n    this.saturation = hsla[1];\n    this.lightness = hsla[2];\n    this.alpha = hsla[3] ?? 1;\n    return this;\n  }\n\n  clone(): YaakColor {\n    return new YaakColor(this.css(), this.appearance);\n  }\n\n  lower(mod: number): YaakColor {\n    return this.appearance === \"dark\" ? this._darken(mod) : this._lighten(mod);\n  }\n\n  lift(mod: number): YaakColor {\n    return this.appearance === \"dark\" ? this._lighten(mod) : this._darken(mod);\n  }\n\n  minLightness(n: number): YaakColor {\n    const c = this.clone();\n    if (c.lightness < n) {\n      c.lightness = n;\n    }\n    return c;\n  }\n\n  isDark(): boolean {\n    return this.lightness < 50;\n  }\n\n  translucify(mod: number): YaakColor {\n    const c = this.clone();\n    c.alpha = c.alpha - c.alpha * mod;\n    return c;\n  }\n\n  opacify(mod: number): YaakColor {\n    const c = this.clone();\n    c.alpha = this.alpha + (100 - this.alpha) * mod;\n    return c;\n  }\n\n  desaturate(mod: number): YaakColor {\n    const c = this.clone();\n    c.saturation = c.saturation - c.saturation * mod;\n    return c;\n  }\n\n  saturate(mod: number): YaakColor {\n    const c = this.clone();\n    c.saturation = this.saturation + (100 - this.saturation) * mod;\n    return c;\n  }\n\n  lighterThan(c: YaakColor): boolean {\n    return this.lightness > c.lightness;\n  }\n\n  css(): string {\n    const h = this.hue;\n    const s = this.saturation;\n    const l = this.lightness;\n    const a = this.alpha;\n\n    const [r, g, b] = parseColor(`hsl(${h},${s}%,${l}%)`).rgb;\n    return rgbaToHex(r, g, b, a);\n  }\n\n  hexNoAlpha(): string {\n    const h = this.hue;\n    const s = this.saturation;\n    const l = this.lightness;\n\n    const [r, g, b] = parseColor(`hsl(${h},${s}%,${l}%)`).rgb;\n    return rgbaToHexNoAlpha(r, g, b);\n  }\n\n  private _lighten(mod: number): YaakColor {\n    const c = this.clone();\n    c.lightness = this.lightness + (100 - this.lightness) * mod;\n    return c;\n  }\n\n  private _darken(mod: number): YaakColor {\n    const c = this.clone();\n    c.lightness = this.lightness - this.lightness * mod;\n    return c;\n  }\n}\n\nfunction rgbaToHex(r: number, g: number, b: number, a: number): string {\n  const toHex = (n: number): string => {\n    const hex = Number(Math.round(n)).toString(16);\n    return hex.length === 1 ? `0${hex}` : hex;\n  };\n  return `#${[toHex(r), toHex(g), toHex(b), toHex(a * 255)].join(\"\").toUpperCase()}`;\n}\n\nfunction rgbaToHexNoAlpha(r: number, g: number, b: number): string {\n  const toHex = (n: number): string => {\n    const hex = Number(Math.round(n)).toString(16);\n    return hex.length === 1 ? `0${hex}` : hex;\n  };\n  return `#${[toHex(r), toHex(g), toHex(b)].join(\"\").toUpperCase()}`;\n}\n\nfunction hexToRgba(hex: string): [number, number, number, number] {\n  const fromHex = (h: string): number => {\n    if (h === \"\") return 255;\n    return Number(`0x${h}`);\n  };\n\n  const r = fromHex(hex.slice(1, 3));\n  const g = fromHex(hex.slice(3, 5));\n  const b = fromHex(hex.slice(5, 7));\n  const a = fromHex(hex.slice(7, 9));\n\n  return [r, g, b, a / 255];\n}\n"
  },
  {
    "path": "src-web/lib/toast.tsx",
    "content": "import { atom } from \"jotai\";\nimport type { ToastInstance } from \"../components/Toasts\";\nimport { generateId } from \"./generateId\";\nimport { jotaiStore } from \"./jotai\";\n\nexport const toastsAtom = atom<ToastInstance[]>([]);\n\nexport function showToast({\n  id,\n  timeout = 5000,\n  ...props\n}: Omit<ToastInstance, \"id\" | \"timeout\" | \"uniqueKey\"> & {\n  id?: ToastInstance[\"id\"];\n  timeout?: ToastInstance[\"timeout\"];\n}) {\n  id = id ?? generateId();\n  const uniqueKey = generateId();\n\n  const toasts = jotaiStore.get(toastsAtom);\n  const toastWithSameId = toasts.find((t) => t.id === id);\n\n  let delay = 0;\n  if (toastWithSameId) {\n    hideToast(toastWithSameId);\n    // Allow enough time for old toast to animate out\n    delay = 200;\n  }\n\n  setTimeout(() => {\n    const newToast: ToastInstance = { id, uniqueKey, timeout, ...props };\n    if (timeout != null) {\n      setTimeout(() => hideToast(newToast), timeout);\n    }\n    jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);\n  }, delay);\n\n  return id;\n}\n\nexport function hideToast(toHide: ToastInstance) {\n  jotaiStore.set(toastsAtom, (all) => {\n    const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);\n    t?.onClose?.();\n    return all.filter((t) => t.uniqueKey !== toHide.uniqueKey);\n  });\n}\n\nexport function showErrorToast<T>({\n  id,\n  title,\n  message,\n}: {\n  id: string;\n  title: string;\n  message: T;\n}) {\n  return showToast({\n    id,\n    color: \"danger\",\n    timeout: null,\n    message: (\n      <div className=\"w-full\">\n        <h2 className=\"text-lg font-bold mb-2\">{title}</h2>\n        <div className=\"whitespace-pre-wrap break-words\">{String(message)}</div>\n      </div>\n    ),\n  });\n}\n"
  },
  {
    "path": "src-web/lib/truncate.ts",
    "content": "export function truncate(text: string, len: number): string {\n  if (text.length <= len) return text;\n  return `${text.slice(0, len)}…`;\n}\n"
  },
  {
    "path": "src-web/main.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n  html,\n  body,\n  #root {\n    @apply w-full h-full overflow-hidden text-text bg-surface;\n  }\n\n  :root {\n    /* Must default these variables or the default will break */\n    --font-family-interface: \"\";\n    --font-family-editor: \"\";\n  }\n\n  /* Never show ligatures */\n  :root {\n    font-variant-ligatures: none;\n  }\n\n  /* The following fixes weird font rendering issues on Linux */\n  html[data-platform=\"linux\"] {\n    font-synthesis: none;\n    text-rendering: optimizeLegibility;\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n    -webkit-text-size-adjust: 100%;\n  }\n\n  ::selection {\n    @apply bg-selection;\n  }\n\n  /* Disable user selection to make it more \"app-like\" */\n  :not(a),\n  :not(input):not(textarea),\n  :not(input):not(textarea)::after,\n  :not(input):not(textarea)::before {\n    @apply select-none cursor-default;\n  }\n\n  input,\n  textarea {\n    &::placeholder {\n      @apply text-placeholder;\n    }\n  }\n\n  a,\n  a[href] * {\n    @apply cursor-pointer !important;\n  }\n\n  table th {\n    @apply text-left;\n  }\n\n  :not(iframe) {\n    &::-webkit-scrollbar,\n    &::-webkit-scrollbar-corner {\n      @apply w-[8px] h-[8px] bg-transparent;\n    }\n\n    &::-webkit-scrollbar-track {\n      @apply bg-transparent;\n    }\n\n    &::-webkit-scrollbar-thumb {\n      @apply bg-text-subtlest rounded-[4px] opacity-20;\n    }\n\n    &::-webkit-scrollbar-thumb:hover {\n      @apply opacity-40 !important;\n    }\n  }\n\n  .hide-scrollbars {\n    &::-webkit-scrollbar-corner,\n    &::-webkit-scrollbar {\n      @apply hidden !important;\n    }\n  }\n\n  .rtl {\n    direction: rtl;\n  }\n\n  :root {\n    color-scheme: light dark;\n    --transition-duration: 100ms ease-in-out;\n    --color-white: 255 100% 100%;\n    --color-black: 255 0% 0%;\n  }\n}\n"
  },
  {
    "path": "src-web/main.tsx",
    "content": "import \"./main.css\";\nimport { RouterProvider } from \"@tanstack/react-router\";\nimport { type } from \"@tauri-apps/plugin-os\";\nimport { changeModelStoreWorkspace, initModelStore } from \"@yaakapp-internal/models\";\nimport { StrictMode } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport { initSync } from \"./init/sync\";\nimport { initGlobalListeners } from \"./lib/initGlobalListeners\";\nimport { jotaiStore } from \"./lib/jotai\";\nimport { router } from \"./lib/router\";\n\nconst osType = type();\ndocument.documentElement.setAttribute(\"data-platform\", osType);\n\nwindow.addEventListener(\"keydown\", (e) => {\n  const rx = /input|select|textarea/i;\n\n  const target = e.target;\n  if (e.key !== \"Backspace\") return;\n  if (!(target instanceof Element)) return;\n  if (target.getAttribute(\"contenteditable\") !== null) return;\n\n  if (\n    !rx.test(target.tagName) ||\n    (\"disabled\" in target && target.disabled) ||\n    (\"readOnly\" in target && target.readOnly)\n  ) {\n    e.preventDefault();\n  }\n});\n\n// Initialize a bunch of watchers\ninitSync();\ninitModelStore(jotaiStore);\ninitGlobalListeners();\nawait changeModelStoreWorkspace(null); // Load global models\n\nconsole.log(\"Creating React root\");\ncreateRoot(document.getElementById(\"root\") as HTMLElement).render(\n  <StrictMode>\n    <RouterProvider router={router} />\n  </StrictMode>,\n);\n"
  },
  {
    "path": "src-web/modules.d.ts",
    "content": "declare module \"vkbeautify\";\n"
  },
  {
    "path": "src-web/package.json",
    "content": "{\n  \"name\": \"@yaakapp/app\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vp dev --force\",\n    \"build\": \"vp build\",\n    \"lint\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@codemirror/commands\": \"^6.8.1\",\n    \"@codemirror/lang-javascript\": \"^6.2.4\",\n    \"@codemirror/lang-json\": \"^6.0.1\",\n    \"@codemirror/lang-markdown\": \"^6.3.2\",\n    \"@codemirror/lang-xml\": \"^6.1.0\",\n    \"@codemirror/lang-yaml\": \"^6.1.2\",\n    \"@codemirror/language\": \"^6.11.0\",\n    \"@codemirror/merge\": \"^6.11.2\",\n    \"@codemirror/search\": \"^6.5.11\",\n    \"@dnd-kit/core\": \"^6.3.1\",\n    \"@gilbarbara/deep-equal\": \"^0.3.1\",\n    \"@lezer/highlight\": \"^1.1.3\",\n    \"@lezer/lr\": \"^1.3.3\",\n    \"@mjackson/multipart-parser\": \"^0.10.1\",\n    \"@prantlf/jsonlint\": \"^16.0.0\",\n    \"@replit/codemirror-emacs\": \"^6.1.0\",\n    \"@replit/codemirror-vim\": \"^6.3.0\",\n    \"@replit/codemirror-vscode-keymap\": \"^6.0.2\",\n    \"@shopify/lang-jsonc\": \"^1.0.1\",\n    \"@tanstack/react-query\": \"^5.90.5\",\n    \"@tanstack/react-router\": \"^1.133.13\",\n    \"@tanstack/react-virtual\": \"^3.13.12\",\n    \"@tauri-apps/api\": \"^2.9.1\",\n    \"@tauri-apps/plugin-clipboard-manager\": \"^2.3.2\",\n    \"@tauri-apps/plugin-dialog\": \"^2.4.2\",\n    \"@tauri-apps/plugin-fs\": \"^2.4.4\",\n    \"@tauri-apps/plugin-log\": \"^2.7.1\",\n    \"@tauri-apps/plugin-opener\": \"^2.5.2\",\n    \"@tauri-apps/plugin-os\": \"^2.3.2\",\n    \"@tauri-apps/plugin-shell\": \"^2.3.3\",\n    \"buffer\": \"^6.0.3\",\n    \"classnames\": \"^2.5.1\",\n    \"cm6-graphql\": \"^0.2.1\",\n    \"codemirror-json-schema\": \"0.6.1\",\n    \"date-fns\": \"^4.1.0\",\n    \"deep-equal\": \"^2.2.3\",\n    \"eventemitter3\": \"^5.0.1\",\n    \"focus-trap-react\": \"^11.0.4\",\n    \"fuzzbunny\": \"^1.0.1\",\n    \"graphql\": \"^16.0.0\",\n    \"hexy\": \"^0.3.5\",\n    \"history\": \"^5.3.0\",\n    \"jotai\": \"^2.12.2\",\n    \"js-md5\": \"^0.8.3\",\n    \"lucide-react\": \"^0.525.0\",\n    \"mime\": \"^4.0.4\",\n    \"motion\": \"^12.4.7\",\n    \"nanoid\": \"^5.0.9\",\n    \"papaparse\": \"^5.4.1\",\n    \"parse-color\": \"^1.0.0\",\n    \"react\": \"^19.2.0\",\n    \"react-colorful\": \"^5.6.1\",\n    \"react-dom\": \"^19.2.0\",\n    \"react-markdown\": \"^10.1.0\",\n    \"react-pdf\": \"^10.0.1\",\n    \"react-syntax-highlighter\": \"^16.1.0\",\n    \"react-use\": \"^17.6.0\",\n    \"rehype-stringify\": \"^10.0.1\",\n    \"remark-frontmatter\": \"^5.0.0\",\n    \"remark-gfm\": \"^4.0.1\",\n    \"slugify\": \"^1.6.6\",\n    \"uuid\": \"^11.1.0\",\n    \"vkbeautify\": \"^0.99.3\",\n    \"whatwg-mimetype\": \"^4.0.0\",\n    \"yaml\": \"^2.6.1\"\n  },\n  \"devDependencies\": {\n    \"@lezer/generator\": \"^1.8.0\",\n    \"@rolldown/plugin-babel\": \"^0.2.1\",\n    \"@tailwindcss/container-queries\": \"^0.1.1\",\n    \"@tailwindcss/nesting\": \"^0.0.0-insiders.565cd3e\",\n    \"@tanstack/router-plugin\": \"^1.127.5\",\n    \"@types/babel__core\": \"^7.20.5\",\n    \"@types/node\": \"^24.0.13\",\n    \"@types/papaparse\": \"^5.3.16\",\n    \"@types/parse-color\": \"^1.0.3\",\n    \"@types/react\": \"^19.2.0\",\n    \"@types/react-dom\": \"^19.2.0\",\n    \"@types/react-syntax-highlighter\": \"^15.5.13\",\n    \"@types/uuid\": \"^10.0.0\",\n    \"@types/whatwg-mimetype\": \"^3.0.2\",\n    \"@vitejs/plugin-react\": \"^6.0.0\",\n    \"autoprefixer\": \"^10.4.21\",\n    \"babel-plugin-react-compiler\": \"^1.0.0\",\n    \"decompress\": \"^4.2.1\",\n    \"internal-ip\": \"^8.0.0\",\n    \"postcss\": \"^8.5.6\",\n    \"postcss-nesting\": \"^13.0.2\",\n    \"tailwindcss\": \"^3.4.17\",\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plugin-static-copy\": \"^3.3.0\",\n    \"vite-plugin-svgr\": \"^4.5.0\",\n    \"vite-plus\": \"latest\"\n  }\n}\n"
  },
  {
    "path": "src-web/postcss.config.cjs",
    "content": "module.exports = {\n  plugins: [\n    require(\"@tailwindcss/nesting\")(require(\"postcss-nesting\")),\n    require(\"tailwindcss\"),\n    require(\"autoprefixer\"),\n  ],\n};\n"
  },
  {
    "path": "src-web/routeTree.gen.ts",
    "content": "/* eslint-disable */\n\n// @ts-nocheck\n\n// noinspection JSUnusedGlobalSymbols\n\n// This file was automatically generated by TanStack Router.\n// You should NOT make any changes in this file as it will be overwritten.\n// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.\n\nimport { Route as rootRouteImport } from \"./routes/__root\";\nimport { Route as IndexRouteImport } from \"./routes/index\";\nimport { Route as WorkspacesIndexRouteImport } from \"./routes/workspaces/index\";\nimport { Route as WorkspacesWorkspaceIdIndexRouteImport } from \"./routes/workspaces/$workspaceId/index\";\nimport { Route as WorkspacesWorkspaceIdSettingsRouteImport } from \"./routes/workspaces/$workspaceId/settings\";\nimport { Route as WorkspacesWorkspaceIdRequestsRequestIdRouteImport } from \"./routes/workspaces/$workspaceId/requests/$requestId\";\n\nconst IndexRoute = IndexRouteImport.update({\n  id: \"/\",\n  path: \"/\",\n  getParentRoute: () => rootRouteImport,\n} as any);\nconst WorkspacesIndexRoute = WorkspacesIndexRouteImport.update({\n  id: \"/workspaces/\",\n  path: \"/workspaces/\",\n  getParentRoute: () => rootRouteImport,\n} as any);\nconst WorkspacesWorkspaceIdIndexRoute = WorkspacesWorkspaceIdIndexRouteImport.update({\n  id: \"/workspaces/$workspaceId/\",\n  path: \"/workspaces/$workspaceId/\",\n  getParentRoute: () => rootRouteImport,\n} as any);\nconst WorkspacesWorkspaceIdSettingsRoute = WorkspacesWorkspaceIdSettingsRouteImport.update({\n  id: \"/workspaces/$workspaceId/settings\",\n  path: \"/workspaces/$workspaceId/settings\",\n  getParentRoute: () => rootRouteImport,\n} as any);\nconst WorkspacesWorkspaceIdRequestsRequestIdRoute =\n  WorkspacesWorkspaceIdRequestsRequestIdRouteImport.update({\n    id: \"/workspaces/$workspaceId/requests/$requestId\",\n    path: \"/workspaces/$workspaceId/requests/$requestId\",\n    getParentRoute: () => rootRouteImport,\n  } as any);\n\nexport interface FileRoutesByFullPath {\n  \"/\": typeof IndexRoute;\n  \"/workspaces\": typeof WorkspacesIndexRoute;\n  \"/workspaces/$workspaceId/settings\": typeof WorkspacesWorkspaceIdSettingsRoute;\n  \"/workspaces/$workspaceId\": typeof WorkspacesWorkspaceIdIndexRoute;\n  \"/workspaces/$workspaceId/requests/$requestId\": typeof WorkspacesWorkspaceIdRequestsRequestIdRoute;\n}\nexport interface FileRoutesByTo {\n  \"/\": typeof IndexRoute;\n  \"/workspaces\": typeof WorkspacesIndexRoute;\n  \"/workspaces/$workspaceId/settings\": typeof WorkspacesWorkspaceIdSettingsRoute;\n  \"/workspaces/$workspaceId\": typeof WorkspacesWorkspaceIdIndexRoute;\n  \"/workspaces/$workspaceId/requests/$requestId\": typeof WorkspacesWorkspaceIdRequestsRequestIdRoute;\n}\nexport interface FileRoutesById {\n  __root__: typeof rootRouteImport;\n  \"/\": typeof IndexRoute;\n  \"/workspaces/\": typeof WorkspacesIndexRoute;\n  \"/workspaces/$workspaceId/settings\": typeof WorkspacesWorkspaceIdSettingsRoute;\n  \"/workspaces/$workspaceId/\": typeof WorkspacesWorkspaceIdIndexRoute;\n  \"/workspaces/$workspaceId/requests/$requestId\": typeof WorkspacesWorkspaceIdRequestsRequestIdRoute;\n}\nexport interface FileRouteTypes {\n  fileRoutesByFullPath: FileRoutesByFullPath;\n  fullPaths:\n    | \"/\"\n    | \"/workspaces\"\n    | \"/workspaces/$workspaceId/settings\"\n    | \"/workspaces/$workspaceId\"\n    | \"/workspaces/$workspaceId/requests/$requestId\";\n  fileRoutesByTo: FileRoutesByTo;\n  to:\n    | \"/\"\n    | \"/workspaces\"\n    | \"/workspaces/$workspaceId/settings\"\n    | \"/workspaces/$workspaceId\"\n    | \"/workspaces/$workspaceId/requests/$requestId\";\n  id:\n    | \"__root__\"\n    | \"/\"\n    | \"/workspaces/\"\n    | \"/workspaces/$workspaceId/settings\"\n    | \"/workspaces/$workspaceId/\"\n    | \"/workspaces/$workspaceId/requests/$requestId\";\n  fileRoutesById: FileRoutesById;\n}\nexport interface RootRouteChildren {\n  IndexRoute: typeof IndexRoute;\n  WorkspacesIndexRoute: typeof WorkspacesIndexRoute;\n  WorkspacesWorkspaceIdSettingsRoute: typeof WorkspacesWorkspaceIdSettingsRoute;\n  WorkspacesWorkspaceIdIndexRoute: typeof WorkspacesWorkspaceIdIndexRoute;\n  WorkspacesWorkspaceIdRequestsRequestIdRoute: typeof WorkspacesWorkspaceIdRequestsRequestIdRoute;\n}\n\ndeclare module \"@tanstack/react-router\" {\n  interface FileRoutesByPath {\n    \"/\": {\n      id: \"/\";\n      path: \"/\";\n      fullPath: \"/\";\n      preLoaderRoute: typeof IndexRouteImport;\n      parentRoute: typeof rootRouteImport;\n    };\n    \"/workspaces/\": {\n      id: \"/workspaces/\";\n      path: \"/workspaces\";\n      fullPath: \"/workspaces\";\n      preLoaderRoute: typeof WorkspacesIndexRouteImport;\n      parentRoute: typeof rootRouteImport;\n    };\n    \"/workspaces/$workspaceId/\": {\n      id: \"/workspaces/$workspaceId/\";\n      path: \"/workspaces/$workspaceId\";\n      fullPath: \"/workspaces/$workspaceId\";\n      preLoaderRoute: typeof WorkspacesWorkspaceIdIndexRouteImport;\n      parentRoute: typeof rootRouteImport;\n    };\n    \"/workspaces/$workspaceId/settings\": {\n      id: \"/workspaces/$workspaceId/settings\";\n      path: \"/workspaces/$workspaceId/settings\";\n      fullPath: \"/workspaces/$workspaceId/settings\";\n      preLoaderRoute: typeof WorkspacesWorkspaceIdSettingsRouteImport;\n      parentRoute: typeof rootRouteImport;\n    };\n    \"/workspaces/$workspaceId/requests/$requestId\": {\n      id: \"/workspaces/$workspaceId/requests/$requestId\";\n      path: \"/workspaces/$workspaceId/requests/$requestId\";\n      fullPath: \"/workspaces/$workspaceId/requests/$requestId\";\n      preLoaderRoute: typeof WorkspacesWorkspaceIdRequestsRequestIdRouteImport;\n      parentRoute: typeof rootRouteImport;\n    };\n  }\n}\n\nconst rootRouteChildren: RootRouteChildren = {\n  IndexRoute: IndexRoute,\n  WorkspacesIndexRoute: WorkspacesIndexRoute,\n  WorkspacesWorkspaceIdSettingsRoute: WorkspacesWorkspaceIdSettingsRoute,\n  WorkspacesWorkspaceIdIndexRoute: WorkspacesWorkspaceIdIndexRoute,\n  WorkspacesWorkspaceIdRequestsRequestIdRoute: WorkspacesWorkspaceIdRequestsRequestIdRoute,\n};\nexport const routeTree = rootRouteImport\n  ._addFileChildren(rootRouteChildren)\n  ._addFileTypes<FileRouteTypes>();\n"
  },
  {
    "path": "src-web/routes/__root.tsx",
    "content": "import { QueryClientProvider } from \"@tanstack/react-query\";\nimport { createRootRoute, Outlet } from \"@tanstack/react-router\";\nimport { type } from \"@tauri-apps/plugin-os\";\nimport classNames from \"classnames\";\nimport { Provider as JotaiProvider } from \"jotai\";\nimport { LazyMotion, MotionConfig } from \"motion/react\";\nimport { lazy, Suspense } from \"react\";\nimport { GlobalHooks } from \"../components/GlobalHooks\";\nimport RouteError from \"../components/RouteError\";\nimport { jotaiStore } from \"../lib/jotai\";\nimport { queryClient } from \"../lib/queryClient\";\n\nconst Toasts = lazy(() => import(\"../components/Toasts\").then((m) => ({ default: m.Toasts })));\nconst Dialogs = lazy(() => import(\"../components/Dialogs\").then((m) => ({ default: m.Dialogs })));\n\nexport const Route = createRootRoute({\n  component: RouteComponent,\n  errorComponent: RouteError,\n});\n\nconst motionFeatures = () => import(\"framer-motion\").then((mod) => mod.domAnimation);\n\nfunction RouteComponent() {\n  return (\n    <JotaiProvider store={jotaiStore}>\n      <QueryClientProvider client={queryClient}>\n        <LazyMotion strict features={motionFeatures}>\n          <MotionConfig transition={{ duration: 0.1 }}>\n            <Suspense>\n              <Toasts />\n              <Dialogs />\n            </Suspense>\n            <Layout />\n            <GlobalHooks />\n          </MotionConfig>\n        </LazyMotion>\n      </QueryClientProvider>\n    </JotaiProvider>\n  );\n}\n\nfunction Layout() {\n  return (\n    <div\n      className={classNames(\"w-full h-full\", type() === \"linux\" && \"border border-border-subtle\")}\n    >\n      <Outlet />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src-web/routes/index.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { RedirectToLatestWorkspace } from \"../components/RedirectToLatestWorkspace\";\n\nexport const Route = createFileRoute(\"/\")({\n  component: RouteComponent,\n});\n\nfunction RouteComponent() {\n  return <RedirectToLatestWorkspace />;\n}\n"
  },
  {
    "path": "src-web/routes/workspaces/$workspaceId/index.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { Workspace } from \"../../../components/Workspace\";\n\ntype WorkspaceSearchSchema = {\n  environment_id?: string | null;\n  cookie_jar_id?: string | null;\n} & (\n  | {\n      request_id: string;\n    }\n  | {\n      folder_id: string;\n    }\n  // oxlint-disable-next-line no-restricted-types -- Needed to support empty\n  | {}\n);\n\nexport const Route = createFileRoute(\"/workspaces/$workspaceId/\")({\n  component: RouteComponent,\n  validateSearch: (search: Record<string, unknown>): WorkspaceSearchSchema => {\n    const base: Pick<WorkspaceSearchSchema, \"environment_id\" | \"cookie_jar_id\"> = {\n      environment_id: search.environment_id as string,\n      cookie_jar_id: search.cookie_jar_id as string,\n    };\n\n    const requestId = search.request_id as string | undefined;\n    const folderId = search.folder_id as string | undefined;\n    if (requestId != null) {\n      return { ...base, request_id: requestId };\n    }\n    if (folderId) {\n      return { ...base, folder_id: folderId };\n    }\n    return base;\n  },\n});\n\nfunction RouteComponent() {\n  return <Workspace />;\n}\n"
  },
  {
    "path": "src-web/routes/workspaces/$workspaceId/requests/$requestId.tsx",
    "content": "import { createFileRoute, Navigate, useParams } from \"@tanstack/react-router\";\n\n// -----------------------------------------------------------------------------------\n// IMPORTANT: This is a deprecated route. Since the active request is optional, it was\n//   moved from a path param to a query parameter. This route does a redirect to the\n//   parent, while preserving the active request.\n\nexport const Route = createFileRoute(\"/workspaces/$workspaceId/requests/$requestId\")({\n  component: RouteComponent,\n});\n\nfunction RouteComponent() {\n  const { workspaceId, requestId } = useParams({\n    from: \"/workspaces/$workspaceId/requests/$requestId\",\n  });\n  return (\n    <Navigate\n      to=\"/workspaces/$workspaceId\"\n      params={{ workspaceId }}\n      search={(prev) => ({ ...prev, requestId })}\n    />\n  );\n}\n"
  },
  {
    "path": "src-web/routes/workspaces/$workspaceId/settings.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport type { SettingsTab } from \"../../../components/Settings/Settings\";\nimport Settings from \"../../../components/Settings/Settings\";\n\ninterface SettingsSearchSchema {\n  tab?: SettingsTab;\n}\n\nexport const Route = createFileRoute(\"/workspaces/$workspaceId/settings\")({\n  component: RouteComponent,\n  validateSearch: (search: Record<string, unknown>): SettingsSearchSchema => ({\n    tab: (search.tab ?? \"general\") as SettingsTab,\n  }),\n});\n\nfunction RouteComponent() {\n  return <Settings />;\n}\n"
  },
  {
    "path": "src-web/routes/workspaces/index.tsx",
    "content": "import { createFileRoute } from \"@tanstack/react-router\";\nimport { RedirectToLatestWorkspace } from \"../../components/RedirectToLatestWorkspace\";\n\nexport const Route = createFileRoute(\"/workspaces/\")({\n  component: RouteComponent,\n});\n\nfunction RouteComponent() {\n  return <RedirectToLatestWorkspace />;\n}\n"
  },
  {
    "path": "src-web/tailwind.config.cjs",
    "content": "const plugin = require(\"tailwindcss/plugin\");\n\nconst sizes = {\n  \"2xs\": \"1.4rem\",\n  xs: \"1.8rem\",\n  sm: \"2.0rem\",\n  md: \"2.3rem\",\n  lg: \"2.6rem\",\n};\n\n/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n  darkMode: [\"class\", '[data-resolved-appearance=\"dark\"]'],\n  content: [\n    \"./*.{html,ts,tsx}\",\n    \"./commands/**/*.{ts,tsx}\",\n    \"./components/**/*.{ts,tsx}\",\n    \"./hooks/**/*.{ts,tsx}\",\n    \"./init/**/*.{ts,tsx}\",\n    \"./lib/**/*.{ts,tsx}\",\n    \"./routes/**/*.{ts,tsx}\",\n  ],\n  theme: {\n    extend: {\n      keyframes: {\n        blinkRing: {\n          \"0%, 49%\": { \"--tw-ring-color\": \"var(--primary)\" },\n          \"50%, 99%\": { \"--tw-ring-color\": \"transparent\" },\n          \"100%\": { \"--tw-ring-color\": \"var(--primary)\" },\n        },\n      },\n      animation: {\n        blinkRing: \"blinkRing 150ms step-start 400ms infinite\",\n      },\n      opacity: {\n        disabled: \"0.3\",\n      },\n      fontSize: {\n        xs: \"0.8rem\",\n      },\n      height: sizes,\n      width: sizes,\n      minHeight: sizes,\n      minWidth: sizes,\n      lineHeight: {\n        // HACK: Minus 2 to account for borders inside inputs\n        xs: \"calc(1.75rem - 2px)\",\n        sm: \"calc(2.0rem - 2px)\",\n        md: \"calc(2.5rem - 2px)\",\n      },\n      transitionProperty: {\n        grid: \"grid\",\n      },\n    },\n    fontFamily: {\n      mono: [\n        \"var(--font-family-editor)\",\n        \"JetBrains Mono\",\n        \"ui-monospace\",\n        \"SFMono-Regular\",\n        \"Menlo\",\n        \"Monaco\",\n        \"Fira Code\",\n        \"Ubuntu Mono\",\n        \"Consolas\",\n        \"Liberation Mono\",\n        \"Courier New\",\n        \"DejaVu Sans Mono\",\n        \"Hack\",\n        \"monospace\",\n      ],\n      sans: [\n        \"var(--font-family-interface)\",\n        \"Inter UI\",\n        \"-apple-system\",\n        \"BlinkMacSystemFont\",\n        \"Segoe UI\",\n        \"Roboto\",\n        \"Oxygen-Sans\",\n        \"Ubuntu\",\n        \"Cantarell\",\n        \"Helvetica Neue\",\n        \"sans-serif\",\n        \"Apple Color Emoji\",\n        \"Segoe UI Emoji\",\n        \"Segoe UI Symbol\",\n      ],\n    },\n    fontSize: {\n      \"4xs\": \"0.6rem\",\n      \"3xs\": \"0.675rem\",\n      \"2xs\": \"0.75rem\",\n      xs: \"0.8rem\",\n      sm: \"0.9rem\",\n      base: \"1rem\",\n      lg: \"1.12rem\",\n      xl: \"1.25rem\",\n      \"2xl\": \"1.5rem\",\n      \"3xl\": \"2rem\",\n      \"4xl\": \"2.5rem\",\n      \"5xl\": \"3rem\",\n      editor: \"var(--editor-font-size)\",\n      shrink: \"0.8em\",\n    },\n    boxShadow: {\n      DEFAULT: \"0 1px 3px 0 var(--shadow)\",\n      lg: \"0 10px 15px -3px var(--shadow)\",\n    },\n    colors: {\n      transparent: \"transparent\",\n      placeholder: \"var(--textSubtlest)\",\n      shadow: \"var(--shadow)\",\n      backdrop: \"var(--backdrop)\",\n      selection: \"var(--selection)\",\n\n      // New theme values\n\n      surface: \"var(--surface)\",\n      \"surface-highlight\": \"var(--surfaceHighlight)\",\n      \"surface-active\": \"var(--surfaceActive)\",\n\n      text: \"var(--text)\",\n      \"text-subtle\": \"var(--textSubtle)\",\n      \"text-subtlest\": \"var(--textSubtlest)\",\n\n      border: \"var(--border)\",\n      \"border-subtle\": \"var(--borderSubtle)\",\n      \"border-focus\": \"var(--borderFocus)\",\n\n      primary: \"var(--primary)\",\n      danger: \"var(--danger)\",\n      secondary: \"var(--secondary)\",\n      success: \"var(--success)\",\n      info: \"var(--info)\",\n      notice: \"var(--notice)\",\n      warning: \"var(--warning)\",\n    },\n  },\n  plugins: [\n    require(\"@tailwindcss/container-queries\"),\n    // oxlint-disable-next-line unbound-method\n    plugin(function ({ addVariant }) {\n      addVariant(\"hocus\", [\"&:hover\", \"&:focus-visible\", \"&.focus:focus\"]);\n      addVariant(\"focus-visible-or-class\", [\"&:focus-visible\", \"&.focus:focus\"]);\n    }),\n  ],\n};\n"
  },
  {
    "path": "src-web/theme.ts",
    "content": "import { listen } from \"@tauri-apps/api/event\";\nimport { getCurrentWebviewWindow } from \"@tauri-apps/api/webviewWindow\";\nimport { setWindowTheme } from \"@yaakapp-internal/mac-window\";\nimport type { ModelPayload } from \"@yaakapp-internal/models\";\nimport { getSettings } from \"./lib/settings\";\nimport type { Appearance } from \"./lib/theme/appearance\";\nimport { getCSSAppearance, subscribeToPreferredAppearance } from \"./lib/theme/appearance\";\nimport { getResolvedTheme } from \"./lib/theme/themes\";\nimport { addThemeStylesToDocument, setThemeOnDocument } from \"./lib/theme/window\";\n\n// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want\n//  a good appearance guess so we're not waiting too long\nlet preferredAppearance: Appearance = getCSSAppearance();\nsubscribeToPreferredAppearance(async (a) => {\n  preferredAppearance = a;\n  await configureTheme();\n});\n\nconfigureTheme().then(\n  async () => {\n    // To prevent theme flashing, the backend hides new windows by default, so we\n    // need to show it here, after configuring the theme for the first time.\n    await getCurrentWebviewWindow().show();\n  },\n  (err) => console.log(\"Failed to configure theme\", err),\n);\n\n// Listen for settings changes, the re-compute theme\nlisten<ModelPayload>(\"model_write\", async (event) => {\n  if (event.payload.change.type !== \"upsert\") return;\n\n  const model = event.payload.model.model;\n  if (model !== \"settings\" && model !== \"plugin\") return;\n  await configureTheme();\n}).catch(console.error);\n\nasync function configureTheme() {\n  const settings = await getSettings();\n  const theme = await getResolvedTheme(\n    preferredAppearance,\n    settings.appearance,\n    settings.themeLight,\n    settings.themeDark,\n  );\n  addThemeStylesToDocument(theme.active);\n  setThemeOnDocument(theme.active);\n  if (theme.active.base.surface != null) {\n    setWindowTheme(theme.active.base.surface);\n  }\n}\n"
  },
  {
    "path": "src-web/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es2021\",\n    \"lib\": [\"DOM\", \"DOM.Iterable\", \"ESNext\"],\n    \"useDefineForClassFields\": true,\n    \"allowJs\": false,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": false,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\"\n  },\n  \"include\": [\".\"],\n  \"exclude\": [\"vite.config.ts\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n"
  },
  {
    "path": "src-web/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"skipLibCheck\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "src-web/tsr.config.json",
    "content": "{\n  \"autoCodeSplitting\": false\n}\n"
  },
  {
    "path": "src-web/vite-env.d.ts",
    "content": "/// <reference types=\"vite-plus/client\" />\n"
  },
  {
    "path": "src-web/vite.config.ts",
    "content": "// @ts-ignore\nimport { tanstackRouter } from \"@tanstack/router-plugin/vite\";\nimport babel from \"@rolldown/plugin-babel\";\nimport react, { reactCompilerPreset } from \"@vitejs/plugin-react\";\nimport { createRequire } from \"node:module\";\nimport path from \"node:path\";\nimport { defineConfig, normalizePath } from \"vite-plus\";\nimport { viteStaticCopy } from \"vite-plugin-static-copy\";\nimport svgr from \"vite-plugin-svgr\";\n\nconst require = createRequire(import.meta.url);\nconst cMapsDir = normalizePath(\n  path.join(path.dirname(require.resolve(\"pdfjs-dist/package.json\")), \"cmaps\"),\n);\nconst standardFontsDir = normalizePath(\n  path.join(path.dirname(require.resolve(\"pdfjs-dist/package.json\")), \"standard_fonts\"),\n);\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  plugins: [\n    tanstackRouter({\n      target: \"react\",\n      routesDirectory: \"./routes\",\n      generatedRouteTree: \"./routeTree.gen.ts\",\n      autoCodeSplitting: true,\n    }),\n    svgr(),\n    react(),\n    babel({\n      presets: [reactCompilerPreset()],\n    }),\n    viteStaticCopy({\n      targets: [\n        { src: cMapsDir, dest: \"\" },\n        { src: standardFontsDir, dest: \"\" },\n      ],\n    }),\n  ],\n  build: {\n    sourcemap: true,\n    outDir: \"../dist\",\n    emptyOutDir: true,\n    rolldownOptions: {\n      output: {\n        // Make chunk names readable\n        chunkFileNames: \"assets/chunk-[name]-[hash].js\",\n        entryFileNames: \"assets/entry-[name]-[hash].js\",\n        assetFileNames: \"assets/asset-[name]-[hash][extname]\",\n      },\n    },\n  },\n  clearScreen: false,\n  server: {\n    port: parseInt(process.env.YAAK_DEV_PORT ?? \"1420\", 10),\n    strictPort: true,\n  },\n  envPrefix: [\"VITE_\", \"TAURI_\"],\n});\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es2021\",\n    \"lib\": [\"DOM\", \"DOM.Iterable\", \"ESNext\"],\n    \"useDefineForClassFields\": true,\n    \"allowJs\": false,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": false,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"types\": [\"node\"]\n  },\n  \"exclude\": [\"flatpak\", \"npm\", \"crates/yaak-templates/pkg\"]\n}\n"
  },
  {
    "path": "vite.config.ts",
    "content": "import { defineConfig } from \"vite-plus\";\n\nexport default defineConfig({\n  lint: {\n    ignorePatterns: [\"npm/**\", \"crates/yaak-templates/pkg/**\", \"**/bindings/gen_*.ts\"],\n    options: {\n      typeAware: true,\n    },\n    rules: {\n      \"typescript/no-explicit-any\": \"error\",\n    },\n  },\n  test: {\n    exclude: [\"**/node_modules/**\", \"**/flatpak/**\"],\n  },\n});\n"
  }
]