diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 29e3c03..234fdb0 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -1,14 +1,7 @@ -name: Auto Release to Marketplace +name: Release To Marketplace on: - push: - branches: [main] workflow_dispatch: - inputs: - force_version: - description: "Force a specific version (e.g., 1.3.0) or leave blank for auto-detect" - required: false - type: string jobs: release: @@ -17,7 +10,6 @@ jobs: timeout-minutes: 30 permissions: contents: write - packages: write steps: - name: Checkout repository @@ -26,80 +18,33 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} + - name: Require main branch + run: | + if [ "${GITHUB_REF}" != "refs/heads/main" ]; then + echo "Run this workflow from main only. Current ref: ${GITHUB_REF}" + exit 1 + fi + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - - name: Get last tag - id: last_tag + - name: Read release version + id: release_version run: | - LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - echo "tag=${LAST_TAG}" >> $GITHUB_OUTPUT - echo "Last tag: ${LAST_TAG:-none}" + VERSION=$(node -p "require('./package.json').version") + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Releasing version ${VERSION} from package.json" - - name: Calculate version bump - id: version + - name: Ensure release tag does not exist env: - FORCE_VERSION: ${{ github.event.inputs.force_version }} - LAST_TAG: ${{ steps.last_tag.outputs.tag }} - run: | - if [ -n "$FORCE_VERSION" ]; then - echo "version=$FORCE_VERSION" >> $GITHUB_OUTPUT - echo "Using forced version: $FORCE_VERSION" - else - # Parse commits to determine version bump - CURRENT_VERSION=$(node -p "require('./package.json').version") - MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1) - MINOR=$(echo $CURRENT_VERSION | cut -d. -f2) - PATCH=$(echo $CURRENT_VERSION | cut -d. -f3) - - # Check commit messages for version bump indicators - if [ -z "$LAST_TAG" ]; then - # No tags yet, check all commits or just HEAD - COMMITS=$(git log HEAD~1..HEAD --pretty=format:%B 2>/dev/null || git log HEAD --pretty=format:%B) - HAS_COMMITS=true - else - # Check commits since last tag - COMMITS=$(git log $LAST_TAG..HEAD --pretty=format:%B) - # Check if there are commits since last tag - COMMIT_COUNT=$(git rev-list --count $LAST_TAG..HEAD) - if [ "$COMMIT_COUNT" -gt 0 ]; then - HAS_COMMITS=true - else - HAS_COMMITS=false - fi - fi - - if echo "$COMMITS" | grep -q "BREAKING CHANGE\|^feat!:"; then - NEW_VERSION="$((MAJOR+1)).0.0" - elif echo "$COMMITS" | grep -qE "^feat"; then - NEW_VERSION="$MAJOR.$((MINOR+1)).0" - elif echo "$COMMITS" | grep -qE "^fix"; then - NEW_VERSION="$MAJOR.$MINOR.$((PATCH+1))" - elif [ "$HAS_COMMITS" = "true" ]; then - # Always bump patch version if there are commits (even if not semantic) - NEW_VERSION="$MAJOR.$MINOR.$((PATCH+1))" - else - NEW_VERSION=$CURRENT_VERSION - fi - - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "Auto-detected version: $NEW_VERSION" - fi - - - name: Check if version changed - id: version_check + RELEASE_TAG: v${{ steps.release_version.outputs.version }} run: | - CURRENT=$(node -p "require('./package.json').version") - NEW_VERSION=${{ steps.version.outputs.version }} - if [ "$CURRENT" = "$NEW_VERSION" ]; then - echo "changed=false" >> $GITHUB_OUTPUT - echo "Version unchanged: $CURRENT" - else - echo "changed=true" >> $GITHUB_OUTPUT - echo "Version will change from $CURRENT to $NEW_VERSION" + if git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then + echo "Tag $RELEASE_TAG already exists. Bump package.json and changelog in git before publishing." + exit 1 fi - name: Install dependencies @@ -121,70 +66,32 @@ jobs: - name: Check compliance run: node scripts/check-extension-compliance.js - - name: Update package.json - if: steps.version_check.outputs.changed == 'true' - run: | - npm version ${{ steps.version.outputs.version }} --no-git-tag-version - - - name: Update CHANGELOG.md - if: steps.version_check.outputs.changed == 'true' - run: | - DATE=$(date +"%Y-%m-%d") - NEW_VERSION=${{ steps.version.outputs.version }} - - # Get commit summary since last tag - if [ -n "${{ steps.last_tag.outputs.tag }}" ]; then - SUMMARY=$(git log ${{ steps.last_tag.outputs.tag }}..HEAD --oneline | sed 's/^/- /') - else - SUMMARY=$(git log HEAD --oneline | head -20 | sed 's/^/- /') - fi - - CHANGELOG_ENTRY="## [$NEW_VERSION] - $DATE - - ### Changes - $SUMMARY - - " - - # Prepend to CHANGELOG.md - (echo "$CHANGELOG_ENTRY" && cat CHANGELOG.md) > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md - - - name: Commit version changes - if: steps.version_check.outputs.changed == 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add package.json CHANGELOG.md - git commit -m "chore: release v${{ steps.version.outputs.version }}" - - - name: Create and push tag - if: steps.version_check.outputs.changed == 'true' - env: - HUSKY: 0 - run: | - git tag -a v${{ steps.version.outputs.version }} -m "Release v${{ steps.version.outputs.version }}" - git push origin main - git push origin v${{ steps.version.outputs.version }} - - - name: Package extension + - name: Build production bundle run: npm run package + - name: Create .vsix file + run: npx -y @vscode/vsce package + - name: Publish to VS Code Marketplace - if: steps.version_check.outputs.changed == 'true' env: VSCE_PAT: ${{ secrets.VSCODE_MARKETPLACE_TOKEN }} - run: npm run deploy + run: npm run publish:marketplace - - name: Create .vsix file for GitHub Release - if: steps.version_check.outputs.changed == 'true' - run: npx -y @vscode/vsce package + - name: Create and push release tag + env: + HUSKY: 0 + RELEASE_TAG: v${{ steps.release_version.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" + git push origin "$RELEASE_TAG" - name: Create GitHub Release uses: softprops/action-gh-release@v1 - if: steps.version_check.outputs.changed == 'true' with: - tag_name: v${{ steps.version.outputs.version }} - name: Release v${{ steps.version.outputs.version }} + tag_name: v${{ steps.release_version.outputs.version }} + name: Release v${{ steps.release_version.outputs.version }} draft: false prerelease: false files: annotative-*.vsix @@ -195,7 +102,7 @@ jobs: uses: actions/upload-artifact@v4 if: always() with: - name: extension-package-${{ steps.version.outputs.version }} + name: extension-package-${{ steps.release_version.outputs.version }} path: annotative-*.vsix retention-days: 30 @@ -203,10 +110,6 @@ jobs: if: always() run: | echo "### Release Status" >> $GITHUB_STEP_SUMMARY - if [ "${{ steps.version_check.outputs.changed }}" = "true" ]; then - echo "โœ… Released v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "- Package: annotative-${{ steps.version.outputs.version }}.vsix" >> $GITHUB_STEP_SUMMARY - echo "- Marketplace: https://marketplace.visualstudio.com/items?itemName=bryan-shea.annotative" >> $GITHUB_STEP_SUMMARY - else - echo "โ„น๏ธ No version change detected - skipped release" >> $GITHUB_STEP_SUMMARY - fi + echo "Released v${{ steps.release_version.outputs.version }} from an already-versioned main commit." >> $GITHUB_STEP_SUMMARY + echo "- Package: annotative-${{ steps.release_version.outputs.version }}.vsix" >> $GITHUB_STEP_SUMMARY + echo "- Marketplace: https://marketplace.visualstudio.com/items?itemName=bryan-shea.annotative" >> $GITHUB_STEP_SUMMARY diff --git a/.husky/commit-msg b/.husky/commit-msg index d49f3af..6a18c62 100644 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,4 +1,9 @@ #!/bin/sh -. "$(dirname "$0")/_/husky.sh" +. "$(dirname "$0")/_/annotative-hook-config.sh" + +if ! annotative_should_enforce_hooks; then + echo "Skipping commit message validation on branch $(annotative_current_branch)" + exit 0 +fi node scripts/validate-commit-msg.js $1 diff --git a/.husky/pre-commit b/.husky/pre-commit index f0d7328..0744f32 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,15 +1,17 @@ #!/bin/sh -. "$(dirname "$0")/_/husky.sh" +. "$(dirname "$0")/_/annotative-hook-config.sh" -# Pre-commit hook: lint and format validation -echo "Running pre-commit checks..." +if ! annotative_should_enforce_hooks; then + echo "Skipping pre-commit checks on branch $(annotative_current_branch)" + exit 0 +fi -# Run ESLint +echo "Running pre-commit checks on protected branch..." echo "Linting code..." -npm run lint --if-present -if [ $? -ne 0 ]; then + +if ! npm run lint --if-present; then echo "Linting failed. Fix errors and try again." exit 1 fi -echo "Pre-commit checks passed\n" \ No newline at end of file +echo "Pre-commit checks passed" \ No newline at end of file diff --git a/.husky/pre-push b/.husky/pre-push index 06828a0..7f50556 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,27 +1,32 @@ #!/bin/sh -. "$(dirname "$0")/_/husky.sh" +. "$(dirname "$0")/_/annotative-hook-config.sh" + +if ! annotative_should_enforce_hooks; then + echo "Skipping pre-push checks on branch $(annotative_current_branch)" + exit 0 +fi # Pre-push hook: validation before pushing to remote -echo "๐Ÿ” Running pre-push checks..." +echo "Running pre-push checks on protected branch..." # Compile TypeScript -echo "๐Ÿ”จ Compiling TypeScript..." +echo "Compiling TypeScript..." npm run compile if [ $? -ne 0 ]; then - echo "โŒ Compilation failed. Fix errors and try again." + echo "Compilation failed. Fix errors and try again." exit 1 fi # Run tests (skip on Windows - tests run in CI) if [ "$OSTYPE" != "msys" ] && [ "$OSTYPE" != "win32" ]; then - echo "๐Ÿงช Running tests..." + echo "Running tests..." npm test if [ $? -ne 0 ]; then - echo "โŒ Tests failed. Fix tests and try again." + echo "Tests failed. Fix tests and try again." exit 1 fi else - echo "โญ๏ธ Skipping tests on Windows (run locally with: npm test)" + echo "Skipping tests on Windows (run locally with: npm test)" fi -echo "โœ… Pre-push checks passed" +echo "Pre-push checks passed" diff --git a/.vscodeignore b/.vscodeignore index e7f37fe..4a29ccb 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -24,6 +24,8 @@ vsc-extension-quickstart.md **/.vscode-test.* logs/** *.log +*.vsix +.changeset-temp.md # CI/CD .github/** diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db5e44..80c7992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,39 @@ -## [3.0.0] - 2026-02-01 - -### Changes -- 90e81af Merge pull request #9 from bryan-shea/feat/updates-optimizations -- 3655a85 feat: introduce user-defined tags and project-based storage system -- 659a063 docs: complete documentation overhaul with professional standards -- 15a5845 refactor!: simplify UX with user-defined tags and project storage - + # Changelog All notable changes to Annotative are documented in this file. +## [3.0.0] - 2026-03-24 + +### Upgrade Notes + +- `v3.0.0` continues the project-based `.annotative/` storage model introduced in `v2` +- Existing `v2.x` workspaces do not require a manual storage migration +- Legacy pre-`v2` global-state annotations are still not imported automatically + +### Added + +- Annotation anchoring and reattachment support to keep saved annotations aligned after nearby source edits +- Dedicated export service and workspace selection helpers for multi-root behavior +- Regression coverage for storage, CRUD, export, manager, anchoring, and sidebar behavior +- A repository test runner that works reliably on Windows paths containing spaces + +### Changed + +- Storage handling now uses schema-versioned payloads and normalization on load +- Export flows are routed through clearer service boundaries for Markdown, AI export, and Copilot-oriented output +- Sidebar webview state handling is more consistent for filtering, grouping, and refresh +- Release automation now publishes an already-versioned `main` commit through a manual GitHub Actions workflow +- Documentation has been rewritten to match the current product, upgrade path, and release flow + +### Fixed + +- Non-atomic storage writes and weak recovery behavior for corrupt storage files +- Annotation persistence edge cases caused by path and workspace resolution issues +- Packaging and release hygiene around temporary files and manual release preparation +- Windows test execution issues in repository paths that include spaces + ## [2.0.0] - 2026-02-01 ### Breaking Changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24a988e..588e95d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,232 +1,138 @@ # Contributing to Annotative -Thank you for your interest in contributing to Annotative. +Annotative is a VS Code extension for code annotation and review workflows. This guide covers the current development, testing, and pull request expectations for the `v3` line. -Annotative is a VS Code extension for code annotation and review workflows. This guide covers development setup, coding standards, and contribution guidelines. +## Prerequisites -## Development Setup - -### Prerequisites - -- Node.js 22.x or higher -- VS Code 1.105.0 or higher +- Node.js `20.x` or `22.x` +- VS Code `1.105.0` or later - Git -### Setup Steps - -1. Fork and clone the repository: - - ```bash - git clone https://github.com/your-username/Annotative.git - cd Annotative - ``` - -2. Install dependencies: +## Local Setup - ```bash - npm install - ``` - -3. Open the project in VS Code: - - ```bash - code . - ``` +```bash +git clone https://github.com/your-username/Annotative.git +cd Annotative +npm install +code . +``` -4. Press F5 to launch the Extension Development Host +Press `F5` in VS Code to launch an Extension Development Host. -### Project Structure +## Project Structure -``` +```text src/ - commands/ # Command implementations - managers/ # Core business logic - tags/ # Tag management system - ui/ # Sidebar and webview components - types.ts # TypeScript interfaces - extension.ts # Extension entry point -media/ # Webview assets (CSS, JS, icons) -docs/ # Documentation -scripts/ # Build and deployment scripts + commands/ Command handlers + managers/ Storage, exports, and annotation logic + tags/ Tag management + ui/ Sidebar webview host code + test/ VS Code integration and manager tests + utils/ Workspace and support helpers +media/ Webview assets +scripts/ Build, validation, and test entry points +docs/ User, maintainer, and testing documentation ``` -## Development Workflow +## Core Commands -### Building +- `npm run compile` builds the extension bundle once +- `npm run watch` runs the extension bundle in watch mode +- `npm run compile-tests` builds the test output +- `npm run watch-tests` watches and rebuilds tests +- `npm run lint` runs ESLint +- `npm test` runs the VS Code test harness +- `npm run quality` runs compile, lint, tests, and compliance checks +- `npm run release:check` runs the local pre-release verification flow -- `npm run compile` - Compile TypeScript once -- `npm run watch` - Compile in watch mode for development -- `npm run package` - Build production bundle with webpack +## Testing Expectations -### Testing - -- `npm test` - Run test suite -- `npm run lint` - Run ESLint -- Manual testing: Press F5 to launch Extension Development Host - -**Important:** Always test locally before pushing to GitHub. See [docs/dev/LOCAL_TESTING_GUIDE.md](docs/dev/LOCAL_TESTING_GUIDE.md) for detailed testing procedures. - -### Quality Checks - -Run these before submitting pull requests: +Before opening a pull request, run: ```bash -npm run lint # Check code style -npm run compile # Verify TypeScript compilation -npm test # Run tests +npm run compile +npm run lint +npm test ``` -Or run all checks at once: +If your change affects release readiness, packaging, or the published extension, also run: ```bash -npm run pretest +npm run release:check ``` -## Code Standards - -### TypeScript +Notes: -- Use strict TypeScript mode -- Define interfaces for all data structures -- Avoid `any` types when possible -- Use async/await for asynchronous operations +- `npm test` uses the repository test runner in `scripts/run-vscode-tests.mjs` +- The runner exists because the stock Windows path handling was unreliable in repo paths with spaces +- Manual verification in an Extension Development Host is still required for UI-heavy changes -### Code Style +## Code Guidelines -- Follow existing code formatting -- Use meaningful variable and function names -- Add JSDoc comments for public APIs -- Keep functions focused and single-purpose - -### Commit Messages - -Follow conventional commit format: - -``` -feat: add new feature -fix: resolve bug -docs: update documentation -refactor: restructure code -test: add or update tests -chore: maintenance tasks -``` +- Keep commands thin and put business logic in managers +- Keep storage and export behavior deterministic and testable +- Follow the current naming used by the extension UI and package manifest +- Use strict TypeScript patterns and avoid unnecessary `any` +- Add or update tests when behavior changes +- Update documentation when user-visible behavior, settings, or release steps change -Examples: +## Pull Request Expectations -- `feat: add batch annotation export` -- `fix: resolve sidebar rendering issue` -- `docs: update README with new commands` +Each pull request should: -### Architecture Guidelines +1. Describe the behavioral change clearly. +2. Include tests or explain why tests were not practical. +3. Update relevant docs when user-facing behavior changes. +4. Avoid unrelated cleanup unless it directly supports the change. -- **Commands** (`src/commands/`) - User-facing command implementations -- **Managers** (`src/managers/`) - Core business logic and state management -- **UI** (`src/ui/`) - Webview and tree view components -- **Tags** (`src/tags/`) - Tag system implementation +Recommended PR checklist: -Keep concerns separated: +- [ ] `package.json`, docs, and commands agree on names and behavior +- [ ] Tests cover the changed path or a reasonable equivalent +- [ ] Screenshots are attached for meaningful UI changes +- [ ] Migration notes are updated if storage or upgrade behavior changed -- Commands handle user input and orchestration -- Managers handle data and business rules -- UI components handle presentation +## Commit Messages -## Pull Request Process +Use conventional commits where practical: -1. Fork the repository -2. Create a feature branch: - - ```bash - git checkout -b feature/your-feature-name - ``` - -3. Make your changes: - - Write code following the standards above - - Add or update tests if applicable - - Update documentation if needed - -4. Test thoroughly: - - Run all quality checks - - Test in Extension Development Host - - Verify no regressions - -5. Commit your changes: - - ```bash - git add . - git commit -m "feat: your feature description" - ``` - -6. Push to your fork: - - ```bash - git push origin feature/your-feature-name - ``` - -7. Open a pull request: - - Provide clear description of changes - - Reference any related issues - - Include screenshots for UI changes - -## Bug Reports - -When filing bug reports, include: - -- **VS Code version** - Help > About -- **Extension version** - Check Extensions view -- **Operating system** - Windows, macOS, or Linux -- **Steps to reproduce** - Numbered steps -- **Expected behavior** - What should happen -- **Actual behavior** - What actually happens -- **Error messages** - From Developer Tools console if available -- **Screenshots** - If UI-related - -## Feature Requests - -When requesting features: - -- Check existing issues first -- Describe the use case -- Explain how it benefits users -- Suggest implementation approach if possible - -## Documentation - -Update documentation when: - -- Adding new features -- Changing existing behavior -- Adding new commands -- Modifying configuration options - -Documentation files: +```text +feat: add anchored annotation reattachment +fix: recover cleanly from corrupt annotation storage +docs: align release process with manual workflow +test: add sidebar webview regression coverage +``` -- [README.md](README.md) - User-facing documentation -- [CHANGELOG.md](CHANGELOG.md) - Version history -- [docs/](docs/) - Technical documentation +## Release Ownership -## Release Process +Maintainers handle releases. -Releases are managed by maintainers. The process includes: +Current release flow: -1. Version bump in package.json -2. Update CHANGELOG.md -3. Create git tag -4. Build and package extension -5. Publish to VS Code Marketplace +1. Prepare the release version and changelog in git. +2. Merge the release-ready commit to `main`. +3. Run `npm run release:check` locally if you are preparing that release commit. +4. Trigger the `Release To Marketplace` GitHub Actions workflow from `main`. +5. Let the workflow validate, package, publish, and tag the already-versioned commit. -Contributors do not need to manage versions or releases. +Contributors should not add ad hoc release automation, bump versions in unrelated PRs, or push directly to `main`. -## Questions +## Reporting Issues -For questions about contributing: +When reporting bugs, include: -- Open a discussion on GitHub -- Check existing documentation in [docs/](docs/) -- Review closed issues for similar topics +- VS Code version +- Annotative version +- Operating system +- Whether you opened a folder or loose files +- Steps to reproduce +- Expected behavior +- Actual behavior +- Relevant output or error text -## Code of Conduct +## Documentation Map -- Be respectful and constructive -- Focus on the technical merits -- Welcome newcomers -- Collaborate openly +- [README.md](README.md) for user-facing behavior +- [MIGRATION.md](MIGRATION.md) for upgrade guidance +- [CHANGELOG.md](CHANGELOG.md) for release notes +- [docs/](docs/) for maintainer and testing documentation diff --git a/MIGRATION.md b/MIGRATION.md index 0e1603e..36e37c2 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,259 +1,87 @@ -# Migration Guide: v1.5.0 to v2.0.0 +# Migration Guide for v3.0.0 -This guide helps you upgrade from Annotative v1.5.0 to v2.0.0, which introduces user-defined tags and project-based storage. +This branch prepares Annotative `v3.0.0`. -## Breaking Changes Overview +For most current users, `v3.0.0` is an in-place upgrade over `v2.x`. The project storage model stays the same, and there is no new manual migration step for existing `.annotative/` data. -### 1. Preset Tags Removed +## Who Needs to Do What -**What Changed:** +### Upgrading from v2.x -- All preset tags (bug, todo, review, question, refactor, documentation, optimization, security) have been removed -- All tags are now user-defined and created per project -- No tags exist by default in new installations +No manual storage migration is required. -**Why:** +Recommended steps: -- Gives teams complete control over their tag system -- Allows customization to match specific workflows -- Eliminates unused preset tags cluttering the interface +1. Update the extension to `v3.0.0`. +2. Open a folder or workspace, not loose files. +3. Confirm `.annotative/annotations.json` and `.annotative/customTags.json` are still present. +4. Open the `Annotations` sidebar and verify annotations, tags, and exports behave as expected. +5. Commit any resulting storage normalization changes if you track `.annotative/` in version control. -### 2. Project-Based Storage +What changes in `v3.0.0`: -**What Changed:** +- Storage writes are more defensive +- Annotation anchoring is more resilient to nearby source edits +- Export handling is cleaner and more consistent +- The release workflow is manual and publishes an already-versioned `main` commit -- Annotations are now automatically stored in `.annotative/` folder in your workspace root -- First annotation you create automatically initializes project storage -- Global storage has been removed -- All annotations are project-scoped +### Upgrading from v1.5.x or Earlier -**Why:** +If you are still on the pre-`v2` model, you still need the legacy migration path: -- Makes it easy to share annotations with your team via version control -- Provides clear separation between different projects -- Eliminates confusion about where annotations are stored +- Preset tags are gone +- Storage is project-based in `.annotative/` +- Legacy global-state annotations are not imported automatically -## Migration Steps +Recommended steps: -### Step 1: Update the Extension +1. Install `v3.0.0`. +2. Recreate the custom tags you want to keep. +3. Initialize project storage with `Annotative: Initialize Storage`, or let the first save create `.annotative/`. +4. Recreate or manually migrate any legacy annotations into `.annotative/annotations.json`. +5. Commit `.annotative/` if you want the project to share annotations and tags. -1. Update Annotative to v2.0.0 from the VS Code Marketplace -2. Restart VS Code if prompted +## Storage Expectations in v3.0.0 -### Step 2: Recreate Your Tag System +Annotative stores project data in: -Since preset tags no longer exist, you'll need to create custom tags for the ones you were using: +- `.annotative/annotations.json` +- `.annotative/customTags.json` -**To create a custom tag:** +The folder is created automatically when needed. You can also create it explicitly with `Annotative: Initialize Storage`. -1. Open Command Palette (Ctrl+Shift+P / Cmd+Shift+P) -2. Run `Annotative: Create Tag` -3. Enter tag name (e.g., "bug", "todo", "review") -4. Select a category and color -5. Repeat for each tag you need +If you want to share annotations with the team, keep `.annotative/` in version control. If you want private local annotations, ignore that folder. -**Suggested tag mappings from v1.5.0 presets:** +## Recommended Upgrade Checklist -| Old Preset | Suggested Custom Tag | Color | Category | -| ------------- | -------------------- | ---------------- | --------- | -| bug | Bug | Red (#FF5252) | issue | -| todo | To-Do | Orange (#FFA726) | action | -| review | Review | Blue (#42A5F5) | action | -| question | Question | Purple (#AB47BC) | reference | -| refactor | Refactor | Green (#66BB6A) | action | -| documentation | Docs | Cyan (#26C6DA) | meta | -| optimization | Optimize | Yellow (#FFEE58) | action | -| security | Security | Red (#FF5252) | issue | - -**Tip:** You can create tags that better match your team's workflow. You're not limited to the old preset names. - -### Step 3: Initialize Project Storage - -**For existing annotations:** - -If you have existing annotations in global storage from v1.5.0: - -1. The first time you add an annotation in v2.0.0, project storage will be automatically created -2. Your existing annotations will continue to work -3. New annotations will be saved to `.annotative/annotations.json` - -**Note:** Existing annotations from v1.5.0 may still reference old preset tag names. You can edit these annotations and reassign them to your new custom tags. - -### Step 4: Update Existing Annotations - -If you have annotations that reference old preset tags: - -1. Open the Annotations sidebar -2. For each annotation with old tags: - - Click the edit button - - Select your new custom tags - - Save the annotation -3. The annotation will now use your custom tags - -**Bulk approach:** - -- You can also delete old annotations and recreate them with new tags -- Use the search feature to find annotations by old tag names - -### Step 5: Share with Your Team - -Now that annotations are stored in `.annotative/`: - -1. Commit the `.annotative/` folder to version control: - - ```bash - git add .annotative/ - git commit -m "Add project annotations" - git push - ``` - -2. Team members pull the changes and install Annotative v2.0.0 -3. They'll automatically see your annotations and custom tags - -**Team coordination:** - -- Make sure all team members create the same custom tags -- Or, one person creates tags and commits them -- Tags are stored in `.annotative/customTags.json` and shared via git - -## Version Control Setup - -### Recommended: Include Annotations in Version Control - -To share annotations with your team: - -```bash -# Add .annotative folder to git -git add .annotative/ -``` - -Your `.annotative/` folder will contain: - -- `annotations.json` - All annotations -- `customTags.json` - Your custom tag definitions -- `.gitignore` - Placeholder (can be deleted) - -### Optional: Exclude Annotations from Version Control - -If you want to keep annotations private: - -1. Add to your project's `.gitignore`: - - ``` - .annotative/ - ``` - -2. Each team member will have their own local annotations - -**Note:** Most teams should include annotations in version control to enable collaboration. - -## Feature Changes - -### Templates Now Prompt for Tags - -In v2.0.0, when you use "Add from Template": - -1. Select a template (Review Code, Explain, Optimize, etc.) -2. If you have custom tags, you'll be prompted to select them -3. The annotation is created with your comment and selected tags - -This makes templates more flexible and useful with your custom tag system. - -### No Manual Storage Initialization - -You no longer need to run "Initialize Storage": - -- Storage is automatically created when you add your first annotation -- The `.annotative/` folder appears in your workspace root -- No configuration required +1. Back up `.annotative/` before a major upgrade. +2. Install or build `v3.0.0`. +3. Open the project in a proper workspace. +4. Verify annotations, tags, sidebar grouping, and exports. +5. Run `Annotative: Storage Info` if you need to confirm the active storage path. ## Troubleshooting -### I don't see my old annotations - -Old annotations from v1.5.0 should still appear. If they don't: - -1. Run `Annotative: Storage Info` to see your storage location -2. Check if a `.annotative/` folder exists in your workspace root -3. If annotations are missing, they may be in global storage from v1.5.0 - -### Tags show as "undefined" or old tag names - -Old annotations may reference preset tags that no longer exist: - -1. Edit the annotation -2. Select your new custom tags -3. Save the annotation - -### My team can't see my tags - -Make sure you've committed `.annotative/customTags.json` to version control: - -```bash -git add .annotative/customTags.json -git commit -m "Add custom tag definitions" -git push -``` - -Team members will see your tags after pulling the changes. - -### Storage folder not created automatically - -If the `.annotative/` folder doesn't appear after adding an annotation: - -1. Check that you have a workspace folder open (not just loose files) -2. Ensure you have write permissions in the workspace directory -3. Check the Output panel (View > Output > Annotative) for errors - -## FAQ - -**Q: Can I use the old preset tag names?** - -A: Yes! Create custom tags with the same names (bug, todo, review, etc.). The functionality will be the same. - -**Q: Do I need to manually initialize storage?** - -A: No. Storage is automatically created when you add your first annotation in v2.0.0. - -**Q: Will my old annotations be lost?** - -A: No. Existing annotations are preserved. They may reference old tag names, which you can update by editing the annotation. - -**Q: Can I share annotations without version control?** - -A: Yes, you can manually share the `.annotative/` folder via file sharing, but version control is recommended for team workflows. - -**Q: What if I don't want any tags?** - -A: Tags are optional. You can create annotations without tags at any time. The tag prompt only appears if you've created custom tags. - -**Q: Can different projects have different tags?** - -A: Yes! Each project's `.annotative/customTags.json` file contains its own tag definitions. This allows per-project customization. +### Storage does not initialize -## Getting Help +Annotative needs an open folder or workspace for project storage. If initialization fails: -If you encounter issues during migration: +1. Open the project as a folder or workspace. +2. Run `Annotative: Initialize Storage` again. +3. Check that VS Code can write to the workspace directory. -1. Check the [README.md](README.md) for updated documentation -2. Review the [CHANGELOG.md](CHANGELOG.md) for all changes -3. Open an issue on [GitHub](https://github.com/bryan-shea/Annotative/issues) with: - - Your migration steps - - Error messages - - Expected vs actual behavior +### Old annotations from pre-v2 do not appear -## Summary +That is expected. Legacy global-state data is not imported automatically. Recreate or manually migrate the data into `.annotative/annotations.json`. -The v2.0.0 upgrade brings: +### Tags no longer match legacy preset names -- More flexibility with user-defined tags -- Simpler project-based storage -- Better team collaboration via version control +Create custom tags that match your workflow, then re-save the affected annotations. -The migration requires: +## Help -- Creating custom tags to replace presets -- Automatic storage initialization (no action needed) -- Committing `.annotative/` to share with your team +If the upgrade does not behave as expected: -Welcome to Annotative v2.0.0! +1. Review [README.md](README.md). +2. Review [CHANGELOG.md](CHANGELOG.md). +3. Open an issue at with your version, workspace setup, and the exact failure. diff --git a/README.md b/README.md index 6e1fae2..be75283 100644 --- a/README.md +++ b/README.md @@ -1,404 +1,137 @@ -![Annotative Logo](media/annotative-logo/128px/annotative-logo.png) - # Annotative -A VS Code extension for code annotation and review workflows. Add inline comments to code selections, organize them with custom tags, and export for documentation or AI-assisted development. +![Annotative Logo](media/annotative-logo/128px/annotative-logo.png) -## Overview +Annotative is a VS Code extension for reviewing code in place. Add annotations to selections, organize them with custom tags, keep them in project storage, and export them for documentation or AI-assisted review. -Annotative provides a lightweight annotation system within VS Code. Highlight code, add comments, organize with tags, and export as Markdown. Annotations are stored per workspace and can be shared with your team via version control. +## Version -Primary use cases: +This branch prepares Annotative `v3.0.0`. -- Reviewing AI-generated code changes -- Documenting code issues during development -- Creating feedback for code reviews -- Taking structured notes within codebases -- Tracking technical debt or improvements +`v3.0.0` keeps the project-based storage model introduced in `v2`, and focuses on stability, storage hardening, anchored annotations, export cleanup, and release hygiene. -## Upgrading from v1.5.0? +Upgrade notes are in [MIGRATION.md](MIGRATION.md). -Annotative v2.0.0 introduces breaking changes to the tag system and storage. See [MIGRATION.md](MIGRATION.md) for detailed upgrade instructions, including: +## What It Does -- How to recreate preset tags as custom tags -- Automatic project storage initialization -- Updating existing annotations with new tags -- Sharing annotations with your team via version control +- Add annotations to code selections from the keyboard or context menu +- Group and filter annotations in the sidebar webview +- Manage project-specific custom tags +- Export annotations to clipboard, a document, or AI-oriented formats +- Save shared annotation data in `.annotative/` +- Integrate with GitHub Copilot Chat through `@annotative` ## Quick Start -1. Select code in the editor -2. Press Ctrl+Shift+A (Cmd+Shift+A on macOS) -3. Enter your comment -4. Optionally select tags and choose a highlight color -5. View all annotations in the sidebar -6. Export to Markdown when ready to share - -## Features - -### Core Functionality - -**Annotation Management** - -- Add annotations to selected code with keyboard shortcuts or context menu -- Edit annotation comments directly in the sidebar -- Mark annotations as resolved when issues are addressed -- Delete individual or bulk annotations -- Undo the most recent annotation - -**Organization** - -- Group annotations by file, tag, or resolution status -- Filter by status (all, open, resolved) -- Filter by custom tags -- Search across all annotations in the workspace -- Navigate between annotations with keyboard shortcuts - -**Custom Tags** - -- Create project-specific tags with custom names and colors -- Edit existing tags to update properties -- Delete unused tags -- No preset tags - all tags are user-defined - -**Visual Highlighting** - -- Inline code highlighting in the editor -- Eight color options for visual preference -- Decorations update automatically when switching files - -**Export and Sharing** - -- Export annotations as Markdown to clipboard -- Export to a new document for editing -- Optimized export formats for AI tools -- Batch export by intent or context - -### GitHub Copilot Integration - -Use the `@annotative` chat participant to interact with your annotations: - -- Review all annotations in the active file -- Ask questions about specific annotations -- Request suggestions for flagged issues -- Copy annotations as context for Copilot - -Chat participant commands: - -- `/issues` - Show open annotations -- `/explain` - Get detailed explanations -- `/fix` - Request fix suggestions -- `/review` - Review with full context - -### Storage Options - -**Project Storage** - -- Initialize `.annotative/` folder in your workspace -- Annotations stored as `annotations.json` in the project -- Share annotations with your team via version control -- Portable across different machines - -**Global Storage** - -- Fallback storage in VS Code's global state -- Per-workspace isolation -- Automatic persistence - -## Installation - -Install from the VS Code Marketplace: - -1. Open VS Code -2. Go to Extensions (Ctrl+Shift+X / Cmd+Shift+X) -3. Search for "Annotative" -4. Click Install - -No additional configuration required - the extension works immediately after installation. - -## Requirements - -- VS Code 1.105.0 or higher -- No external dependencies - -## Usage - -### Adding Annotations - -**Via Keyboard Shortcut:** - -1. Select text in the editor -2. Press Ctrl+Shift+A (Cmd+Shift+A on macOS) -3. Enter a comment in the input box -4. Optionally select tags (if any custom tags exist) -5. Choose a highlight color - -**Via Context Menu:** - -1. Select text in the editor -2. Right-click and choose "Add Annotation" -3. Follow the same prompts as above - -**Via Template:** - -- Select text and use "Add from Template" for common annotation scenarios -- Templates include predefined comments for review, explanation, optimization, and security - -### Managing Annotations - -**Sidebar View:** - -- All annotations appear in the "Annotations" sidebar -- Click any annotation to navigate to its location -- Use inline buttons to edit, toggle status, or delete -- Group annotations by file, tag, or status using the dropdown - -**Editing:** - -- Click the edit button in the sidebar -- Update the comment text in the input box -- Changes save automatically +1. Open a folder or workspace in VS Code. +2. Select code. +3. Run `Annotative: Add Annotation` or press `Ctrl+Shift+A` on Windows/Linux or `Cmd+Shift+A` on macOS. +4. Enter a comment, then optionally choose tags and a highlight color. +5. Open the `Annotations` view in the activity bar to review, filter, and export annotations. -**Resolution Tracking:** +## Storage Model -- Click the toggle status button to mark as resolved -- Resolved annotations remain visible but are flagged -- Use "Delete Resolved" to clean up completed items +Annotative stores project data in `.annotative/` at the workspace root. -**Bulk Operations:** +- `annotations.json` stores annotations +- `customTags.json` stores custom tag definitions +- `README.md` explains how to include or ignore the folder in version control -- "Resolve All" marks all annotations as resolved -- "Delete Resolved" removes all resolved annotations -- "Delete All" clears all annotations in the workspace +Storage is created automatically on first save, or explicitly with `Annotative: Initialize Storage`. -### Filtering and Search +## Key Capabilities -**Filter by Status:** +### Annotation workflow -- Show all, only open, or only resolved annotations -- Access via the filter icon in the sidebar toolbar - -**Filter by Tag:** - -- Focus on annotations with specific tags -- Only available if custom tags have been created - -**Search:** - -- Press Ctrl+Shift+F (Cmd+Shift+F on macOS) in the sidebar -- Search across comments, file paths, and line numbers -- Results update in real-time - -### Keyboard Shortcuts - -| Shortcut | Action | -| -------------------------- | ---------------------------------- | -| Ctrl+Shift+A (Cmd+Shift+A) | Add annotation to selection | -| Ctrl+Shift+Z (Cmd+Shift+Z) | Undo last annotation | -| Alt+Down | Navigate to next annotation | -| Alt+Up | Navigate to previous annotation | -| Ctrl+Shift+F (Cmd+Shift+F) | Search annotations in sidebar | -| Ctrl+Shift+C (Cmd+Shift+C) | Copy annotation as Copilot context | -| Ctrl+Alt+E (Cmd+Alt+E) | Export annotations by intent | - -### Custom Tags - -**Creating Tags:** - -1. Open Command Palette (Ctrl+Shift+P / Cmd+Shift+P) -2. Run "Annotative: Create Tag" -3. Enter tag name -4. Select color and priority - -**Editing Tags:** - -1. Open Command Palette -2. Run "Annotative: Edit Tag" -3. Select tag to edit -4. Update properties - -**Deleting Tags:** - -1. Open Command Palette -2. Run "Annotative: Delete Tag" -3. Select tag to remove -4. Confirm deletion - -**Viewing Tags:** - -- Run "Annotative: List Tags" to see all available tags - -### Project Storage - -**Initialize Project Storage:** - -1. Open Command Palette -2. Run "Annotative: Initialize Storage" -3. A `.annotative/` folder is created in the workspace root -4. Annotations are saved to `.annotative/annotations.json` - -**Share with Team:** - -- Commit `.annotative/` to version control -- Team members with the extension see the same annotations -- Changes sync through git like any other file +- Add, edit, remove, and toggle annotation status +- Undo the most recent annotation +- Navigate to previous and next annotations in the active file +- Keep inline decorations synchronized with saved annotations -**Check Storage Location:** +### Sidebar workflow -- Run "Annotative: Storage Info" to see current storage path -- Shows whether using project or global storage +- Group by file, folder, tag, or status +- Filter by status and tag +- Search within the current annotation set +- Run bulk actions such as `Resolve All`, `Delete Resolved`, and `Delete All` -### Exporting Annotations +### Tag workflow -**To Clipboard:** +- Create user-defined tags with category, priority, and color +- Edit or delete existing tags +- Share tag definitions through `.annotative/customTags.json` -- Click "Export to Clipboard" in the sidebar toolbar -- Markdown content copied to clipboard -- Paste into documents, chats, or issues +### Export workflow -**To Document:** +- Export Markdown to the clipboard +- Open an export in a new untitled document +- Export for Copilot, ChatGPT, Claude, or a generic AI target +- Export by intent for review, bugs, optimization, or documentation +- Save Copilot-oriented exports under `.copilot/annotations` -- Click "Export to Document" in the sidebar toolbar -- Opens a new untitled document with Markdown content -- Edit and save as needed +## Copilot Integration -**For AI Tools:** +When GitHub Copilot Chat is installed and `annotative.copilot.enabled` is enabled, Annotative registers an `@annotative` chat participant. -- Click "Export for AI" for optimized formatting -- Includes context lines and code snippets -- Paste directly into Copilot, ChatGPT, or Claude +Supported commands: -**By Intent:** +- `/issues` +- `/explain` +- `/fix` +- `/review` -- Press Ctrl+Alt+E (Cmd+Alt+E on macOS) -- Choose export intent (review, documentation, issue tracking) -- Format optimized for the selected intent +Annotative can also prepare AI-specific exports and optionally open the Copilot Chat panel after export. ## Commands -Access all commands via the Command Palette (Ctrl+Shift+P / Cmd+Shift+P): - -**Annotation Commands:** - -- `Annotative: Add Annotation` - Add annotation to selected code -- `Annotative: Add from Template` - Use predefined templates -- `Annotative: Edit` - Edit annotation comment -- `Annotative: Toggle Status` - Mark resolved or unresolved -- `Annotative: Remove` - Delete annotation -- `Annotative: Undo` - Undo last annotation -- `Annotative: View Details` - Show annotation details +Key command groups: -**Navigation Commands:** +- Annotation: `Add Annotation`, `Add from Template`, `Edit`, `Toggle Status`, `Remove`, `Undo`, `View Details` +- Navigation: `Next`, `Previous`, `Go to Location` +- Filters: `Filter by Status`, `Filter by Tag`, `Search`, `Clear Filters`, `Refresh` +- Bulk: `Resolve All`, `Delete Resolved`, `Delete All` +- Tags: `Create Tag`, `Edit Tag`, `Delete Tag`, `List Tags` +- Export: `Export to Clipboard`, `Export to Document`, `Export by Intent`, `Export for AI`, `Batch AI Review` +- Storage: `Initialize Storage`, `Storage Info` -- `Annotative: Next` - Go to next annotation -- `Annotative: Previous` - Go to previous annotation -- `Annotative: Go to Location` - Navigate to annotation source +## Keyboard Shortcuts -**Filter Commands:** - -- `Annotative: Filter by Status` - Filter all, open, or resolved -- `Annotative: Filter by Tag` - Filter by custom tag -- `Annotative: Search` - Search annotations -- `Annotative: Clear Filters` - Reset all filters -- `Annotative: Refresh` - Reload annotations - -**Bulk Commands:** - -- `Annotative: Resolve All` - Mark all as resolved -- `Annotative: Delete Resolved` - Remove resolved annotations -- `Annotative: Delete All` - Clear all annotations - -**Tag Commands:** - -- `Annotative: Create Tag` - Create custom tag -- `Annotative: Edit Tag` - Modify tag properties -- `Annotative: Delete Tag` - Remove custom tag -- `Annotative: List Tags` - View all tags - -**Export Commands:** - -- `Annotative: Export to Clipboard` - Copy as Markdown -- `Annotative: Export to Document` - Open in new file -- `Annotative: Export by Intent` - Optimized export formats -- `Annotative: Export for AI` - AI-optimized format -- `Annotative: Batch AI Review` - Prepare for AI review - -**Storage Commands:** - -- `Annotative: Initialize Storage` - Create project storage -- `Annotative: Storage Info` - Show storage location - -**Copilot Commands:** - -- `Annotative: Ask Copilot` - Query Copilot about annotation -- `Annotative: Copy for Copilot` - Copy as Copilot context +| Shortcut | Action | +| ------------------------------ | ---------------- | +| `Ctrl+Shift+A` / `Cmd+Shift+A` | Add Annotation | +| `Ctrl+Shift+Z` / `Cmd+Shift+Z` | Undo | +| `Alt+Down` | Next | +| `Alt+Up` | Previous | +| `Ctrl+Alt+E` / `Cmd+Alt+E` | Export by Intent | ## Configuration -Configure via VS Code settings (File > Preferences > Settings): +Annotative currently exposes these settings: -**Export Settings:** +- `annotative.export.contextLines` +- `annotative.export.includeImports` +- `annotative.copilot.enabled` +- `annotative.copilot.autoAttachContext` +- `annotative.copilot.preferredFormat` +- `annotative.copilot.autoOpenChat` -- `annotative.export.contextLines` - Number of context lines in exports (default: 5) -- `annotative.export.includeImports` - Include imports in context (default: true) -- `annotative.export.includeFunction` - Include full function definitions (default: true) -- `annotative.export.copilotOptimized` - Optimize format for Copilot (default: true) - -**Copilot Integration:** - -- `annotative.copilot.enabled` - Enable Copilot integration (default: true) -- `annotative.copilot.autoAttachContext` - Auto-attach context to Copilot (default: true) -- `annotative.copilot.preferredFormat` - Export format for Copilot: conversational, structured, or compact (default: conversational) -- `annotative.copilot.showInlineButtons` - Show Copilot buttons inline (default: true) -- `annotative.copilot.autoOpenChat` - Auto-open Copilot Chat (default: false) - -## Workflows - -### Code Review - -1. Open a pull request or diff locally -2. Annotate areas needing attention -3. Use tags to categorize feedback -4. Export annotations and share with the team -5. Mark annotations as resolved when addressed - -### AI-Assisted Development - -1. Review AI-generated code changes -2. Annotate unclear or problematic sections -3. Use `@annotative` in Copilot Chat to discuss -4. Or export annotations to ChatGPT or Claude -5. Implement suggestions and resolve annotations - -### Documentation - -1. Annotate complex code sections -2. Use tags like "docs" or "clarification" -3. Export as Markdown -4. Integrate into project documentation +## Requirements -### Issue Tracking +- VS Code `1.105.0` or later +- A folder or workspace for project storage features +- GitHub Copilot Chat only if you want Copilot-specific commands or exports -1. Create annotations for technical debt -2. Tag by priority or category -3. Filter to focus on high-priority items -4. Export and create GitHub issues -5. Resolve annotations as issues are fixed +## Upgrade Notes -## Contributing +- Upgrading from `v2.x` to `v3.0.0`: no manual storage migration is required. +- Upgrading from `v1.5.x` or earlier: legacy global-state data is not imported automatically. -Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. +See [MIGRATION.md](MIGRATION.md) for the full upgrade path. -**Development Setup:** +## Development -1. Clone the repository -2. Run `npm install` -3. Press F5 in VS Code to launch Extension Development Host -4. Make changes and test -5. Submit a pull request +Development setup, testing, and contribution guidance are in [CONTRIBUTING.md](CONTRIBUTING.md). ## License -MIT License. See [LICENSE](LICENSE) for details. - -This extension is free for personal, commercial, and enterprise use with no restrictions. +MIT. See [LICENSE](LICENSE). diff --git a/media/sidebar-webview.js b/media/sidebar-webview.js index 7b17a9e..c12fbb8 100644 --- a/media/sidebar-webview.js +++ b/media/sidebar-webview.js @@ -12,9 +12,11 @@ const vscode = acquireVsCodeApi(); const state = { annotations: [], availableTags: [], + tagLabels: {}, filters: { status: 'all', tag: 'all', + search: '', groupBy: 'file', }, }; @@ -96,8 +98,8 @@ class AnnotationHandlers { } // Get current tags - const currentTags = annotation.tags?.map((t) => (typeof t === 'string' ? t : t.id)) || []; - const filteredTags = availableTags.filter((tag) => !currentTags.includes(tag)); + const currentTags = annotation.tags || []; + const filteredTags = availableTags.filter((tag) => !currentTags.includes(tag.id)); if (filteredTags.length === 0) { // All tags already added @@ -207,10 +209,7 @@ function filterAnnotations(annotations, filters) { } if (filters.tag && filters.tag !== 'all') { - const hasTag = ann.tags?.some((t) => { - const tagId = typeof t === 'string' ? t : t.id; - return tagId === filters.tag; - }); + const hasTag = ann.tags?.some((tagId) => tagId === filters.tag); if (!hasTag) { return false; } @@ -224,8 +223,7 @@ function extractTags(annotations) { const tags = new Set(); annotations.forEach((ann) => { if (ann.tags && Array.isArray(ann.tags)) { - ann.tags.forEach((t) => { - const tagId = typeof t === 'string' ? t : t.id; + ann.tags.forEach((tagId) => { tags.add(tagId); }); } @@ -233,6 +231,10 @@ function extractTags(annotations) { return Array.from(tags).sort(); } +function getTagLabel(tagId) { + return state.tagLabels[tagId] || tagId; +} + function calculateStats(annotations) { const resolved = annotations.filter((a) => a.resolved).length; return { @@ -243,23 +245,44 @@ function calculateStats(annotations) { } function updateTagFilter(filterElement, tags, currentValue) { - const currentTag = filterElement.value; + const selectedTag = currentValue || 'all'; filterElement.innerHTML = ''; tags.forEach((tag) => { const option = document.createElement('option'); option.value = tag; - option.textContent = tag; + option.textContent = getTagLabel(tag); filterElement.appendChild(option); }); - if (currentTag && tags.includes(currentTag)) { - filterElement.value = currentTag; + if (selectedTag && selectedTag !== 'all' && tags.includes(selectedTag)) { + filterElement.value = selectedTag; } else { filterElement.value = 'all'; } } +function updateFilterControls(filters) { + if (elements.statusFilter) { + elements.statusFilter.value = filters.status; + } + + if (elements.groupBySelect) { + elements.groupBySelect.value = filters.groupBy; + } + + if (elements.tagFilter) { + updateTagFilter(elements.tagFilter, extractTags(state.annotations), filters.tag); + } +} + +function notifyFilterStateChanged() { + vscode.postMessage({ + command: 'filterStateChanged', + filters: { ...state.filters }, + }); +} + // ==================== Rendering ==================== function createAnnotationCard(annotation) { const card = document.createElement('div'); @@ -303,17 +326,16 @@ function createAnnotationCard(annotation) { tagsContainer.className = 'card-tags'; if (annotation.tags && annotation.tags.length > 0) { - annotation.tags.forEach((tag) => { - const tagId = typeof tag === 'string' ? tag : tag.id; + annotation.tags.forEach((tagId) => { const tagEl = document.createElement('span'); tagEl.className = `tag tag-${tagId}`; - tagEl.textContent = tagId; + tagEl.textContent = getTagLabel(tagId); // Add remove button const removeBtn = document.createElement('button'); removeBtn.className = 'tag-remove'; removeBtn.innerHTML = ''; - removeBtn.title = `Remove ${tagId} tag`; + removeBtn.title = `Remove ${getTagLabel(tagId)} tag`; removeBtn.dataset.action = 'removeTag'; removeBtn.dataset.annotationId = annotation.id; removeBtn.dataset.tag = tagId; @@ -398,15 +420,12 @@ function groupAnnotations(annotations, groupBy) { key = parts.length > 1 ? parts[parts.length - 2] : 'Root'; } else if (groupBy === 'tag') { if (ann.tags && ann.tags.length > 0) { - const tagId = typeof ann.tags[0] === 'string' ? ann.tags[0] : ann.tags[0].id; - key = tagId; + key = getTagLabel(ann.tags[0]); } else { key = 'Untagged'; } } else if (groupBy === 'status') { key = ann.resolved ? 'Resolved' : 'Unresolved'; - } else if (groupBy === 'priority') { - key = ann.priority || 'Default'; } if (!groups[key]) { @@ -497,6 +516,7 @@ function setupEventListeners() { } elements.statusFilter?.addEventListener('change', (e) => { state.filters.status = e.target.value; + notifyFilterStateChanged(); applyFiltersAndRender(); }); @@ -505,6 +525,7 @@ function setupEventListeners() { } elements.tagFilter?.addEventListener('change', (e) => { state.filters.tag = e.target.value; + notifyFilterStateChanged(); applyFiltersAndRender(); }); @@ -513,6 +534,7 @@ function setupEventListeners() { } elements.groupBySelect?.addEventListener('change', (e) => { state.filters.groupBy = e.target.value; + notifyFilterStateChanged(); applyFiltersAndRender(); }); @@ -564,6 +586,9 @@ function handleExtensionMessage(message) { case 'annotationUpdated': handleAnnotationUpdated(message.annotation); break; + case 'filterStateUpdated': + handleFilterStateUpdated(message.filters || {}); + break; default: console.warn('[Annotative] Unknown command:', message.command); } @@ -581,8 +606,7 @@ function requestAnnotations() { function handleUpdateAnnotations(annotations) { try { state.annotations = annotations || []; - const tags = extractTags(state.annotations); - updateTagFilter(elements.tagFilter, tags, state.filters.tag); + updateFilterControls(state.filters); applyFiltersAndRender(); } catch (error) { console.error('[Annotative] Error updating annotations:', error); @@ -591,14 +615,24 @@ function handleUpdateAnnotations(annotations) { function handleTagsUpdated(tags) { try { - // Store available tags in state for use by tag picker - state.availableTags = tags || []; - updateTagFilter(elements.tagFilter, tags, state.filters.tag); + const availableTags = Array.isArray(tags) ? tags : []; + state.availableTags = availableTags; + state.tagLabels = Object.fromEntries(availableTags.map((tag) => [tag.id, tag.label])); + updateTagFilter(elements.tagFilter, extractTags(state.annotations), state.filters.tag); } catch (error) { console.error('[Annotative] Error updating tags:', error); } } +function handleFilterStateUpdated(filters) { + state.filters = { + ...state.filters, + ...filters, + }; + updateFilterControls(state.filters); + applyFiltersAndRender(); +} + function handleAnnotationAdded(annotation) { if (annotation) { state.annotations.push(annotation); @@ -674,17 +708,17 @@ function showTagPicker(annotation, availableTags, onSelect) { list.innerHTML = ''; // Create tag picker items - availableTags.forEach((tagId) => { + availableTags.forEach((tag) => { const item = document.createElement('button'); item.className = 'tag-picker-item'; const tagEl = document.createElement('span'); - tagEl.className = `tag tag-${tagId}`; - tagEl.textContent = tagId; + tagEl.className = `tag tag-${tag.id}`; + tagEl.textContent = tag.label; item.appendChild(tagEl); item.addEventListener('click', () => { - onSelect(tagId); + onSelect(tag.id); hideTagPicker(); }); diff --git a/package-lock.json b/package-lock.json index e76b179..6a346ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,29 +1,29 @@ { "name": "annotative", - "version": "1.3.52", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "annotative", - "version": "1.3.52", + "version": "3.0.0", "license": "MIT", "devDependencies": { "@types/mocha": "^10.0.10", - "@types/node": "22.x", + "@types/node": "^22.19.15", "@types/vscode": "^1.105.0", - "@typescript-eslint/eslint-plugin": "^8.45.0", - "@typescript-eslint/parser": "^8.45.0", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", "@vscode/codicons": "^0.0.42", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", - "@vscode/vsce": "^3.6.2", + "@vscode/vsce": "^3.7.1", "copy-webpack-plugin": "^13.0.1", - "eslint": "^9.36.0", + "eslint": "^9.39.4", "husky": "^9.1.7", "ts-loader": "^9.5.4", "typescript": "^5.9.3", - "webpack": "^5.102.0", + "webpack": "^5.105.4", "webpack-cli": "^6.0.1" }, "engines": { @@ -259,9 +259,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -278,9 +278,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -288,15 +288,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -314,9 +314,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -327,22 +327,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", - "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^0.17.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", - "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -353,20 +353,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -398,9 +398,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -411,9 +411,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", - "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -434,13 +434,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", - "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -1041,9 +1041,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.18.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.11.tgz", - "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==", + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", "dependencies": { @@ -1072,21 +1072,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", - "integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/type-utils": "8.46.2", - "@typescript-eslint/utils": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1096,23 +1095,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.2", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz", - "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1122,20 +1121,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz", - "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.2", - "@typescript-eslint/types": "^8.46.2", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1149,14 +1148,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz", - "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2" + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1167,9 +1166,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz", - "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", "dev": true, "license": "MIT", "engines": { @@ -1184,17 +1183,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz", - "integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2", - "@typescript-eslint/utils": "8.46.2", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1204,14 +1203,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz", - "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", "dev": true, "license": "MIT", "engines": { @@ -1223,22 +1222,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz", - "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.2", - "@typescript-eslint/tsconfig-utils": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1251,17 +1249,56 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz", - "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1271,19 +1308,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz", - "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.2", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1294,13 +1331,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -1370,9 +1407,9 @@ } }, "node_modules/@vscode/vsce": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.2.tgz", - "integrity": "sha512-gvBfarWF+Ii20ESqjA3dpnPJpQJ8fFJYtcWtjwbRADommCzGg1emtmb34E+DKKhECYvaVyAl+TF9lWS/3GSPvg==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz", + "integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==", "dev": true, "license": "MIT", "dependencies": { @@ -1901,9 +1938,9 @@ "license": "Apache-2.0" }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -1947,9 +1984,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2129,13 +2166,16 @@ "optional": true }, "node_modules/baseline-browser-mapping": { - "version": "2.8.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", - "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==", + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -2241,9 +2281,9 @@ "license": "ISC" }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -2261,11 +2301,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -2421,9 +2461,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001751", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", - "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", "dev": true, "funding": [ { @@ -3089,9 +3129,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.237", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", - "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", + "version": "1.5.322", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.322.tgz", + "integrity": "sha512-vFU34OcrvMcH66T+dYC3G4nURmgfDVewMIu6Q2urXpumAPSMmzvcn04KVVV8Opikq8Vs5nUbO/8laNhNRqSzYw==", "dev": true, "license": "ISC" }, @@ -3128,14 +3168,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -3201,9 +3241,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -3260,25 +3300,25 @@ } }, "node_modules/eslint": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", - "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.1", - "@eslint/core": "^0.16.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.38.0", - "@eslint/plugin-kit": "^0.4.0", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -3297,7 +3337,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3397,9 +3437,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3455,9 +3495,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3674,9 +3714,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -3934,13 +3974,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4593,9 +4626,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -5258,9 +5291,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.25", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.25.tgz", - "integrity": "sha512-4auku8B/vw5psvTiiN9j1dAOsXvMoGqJuKJcR+dTdqiXEK20mMTk1UEo3HS16LeGQsVG6+qKTPM9u/qQ2LqATA==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "dev": true, "license": "MIT" }, @@ -7029,9 +7062,9 @@ } }, "node_modules/terser": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", - "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -7048,16 +7081,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { @@ -7238,9 +7270,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -7409,9 +7441,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -7513,9 +7545,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", - "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "dev": true, "license": "MIT", "dependencies": { @@ -7527,9 +7559,9 @@ } }, "node_modules/webpack": { - "version": "5.102.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", - "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "dev": true, "license": "MIT", "dependencies": { @@ -7539,25 +7571,25 @@ "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.4", - "webpack-sources": "^3.3.3" + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" @@ -7644,9 +7676,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", - "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index c7344aa..2c49f60 100644 --- a/package.json +++ b/package.json @@ -286,43 +286,6 @@ "when": "view == annotativeView", "group": "3_bulk@3" } - ], - "view/item/context": [ - { - "command": "annotative.viewAnnotation", - "when": "view == annotativeView && (viewItem == unresolvedAnnotation || viewItem == resolvedAnnotation)", - "group": "inline@1" - }, - { - "command": "annotative.editAnnotation", - "when": "view == annotativeView && (viewItem == unresolvedAnnotation || viewItem == resolvedAnnotation)", - "group": "inline@2" - }, - { - "command": "annotative.toggleResolved", - "when": "view == annotativeView && viewItem == unresolvedAnnotation", - "group": "inline@3" - }, - { - "command": "annotative.toggleResolved", - "when": "view == annotativeView && viewItem == resolvedAnnotation", - "group": "inline@3" - }, - { - "command": "annotative.removeAnnotation", - "when": "view == annotativeView && (viewItem == unresolvedAnnotation || viewItem == resolvedAnnotation)", - "group": "inline@4" - }, - { - "command": "annotative.askCopilotAboutAnnotation", - "when": "view == annotativeView && (viewItem == unresolvedAnnotation || viewItem == resolvedAnnotation)", - "group": "copilot@1" - }, - { - "command": "annotative.copyAsCopilotContext", - "when": "view == annotativeView && (viewItem == unresolvedAnnotation || viewItem == resolvedAnnotation)", - "group": "copilot@2" - } ] }, "chatParticipants": [ @@ -408,16 +371,6 @@ "default": true, "description": "Include imports in context" }, - "annotative.export.includeFunction": { - "type": "boolean", - "default": true, - "description": "Include full function definition" - }, - "annotative.export.copilotOptimized": { - "type": "boolean", - "default": true, - "description": "Optimize format for Copilot" - }, "annotative.copilot.enabled": { "type": "boolean", "default": true, @@ -438,11 +391,6 @@ "default": "conversational", "description": "Export format for Copilot" }, - "annotative.copilot.showInlineButtons": { - "type": "boolean", - "default": true, - "description": "Show Copilot buttons inline" - }, "annotative.copilot.autoOpenChat": { "type": "boolean", "default": false, @@ -462,33 +410,34 @@ "compile-tests": "tsc -p . --outDir out", "watch-tests": "tsc -p . -w --outDir out", "pretest": "npm run compile-tests && npm run compile && npm run lint", + "quality": "npm run compile && npm run lint && npm test && node scripts/check-extension-compliance.js", + "release:check": "npm run quality && npm run package", "lint": "eslint src", - "test": "vscode-test", + "test": "node ./scripts/run-vscode-tests.mjs", "dev": "npm run watch", - "ci": "git add . && npm run changeset:enhanced && git push origin main --no-verify", "clean": "rimraf dist out", "changeset": "node scripts/changeset.js", "changeset:enhanced": "node scripts/changeset-enhanced.js", "version:check": "node scripts/calculate-version.js", "version:validate-msg": "node scripts/validate-commit-msg.js", - "deploy": "vsce publish --no-git-tag-version" + "publish:marketplace": "vsce publish --no-git-tag-version" }, "devDependencies": { "@types/mocha": "^10.0.10", - "@types/node": "22.x", + "@types/node": "^22.19.15", "@types/vscode": "^1.105.0", - "@typescript-eslint/eslint-plugin": "^8.45.0", - "@typescript-eslint/parser": "^8.45.0", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", "@vscode/codicons": "^0.0.42", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", - "@vscode/vsce": "^3.6.2", + "@vscode/vsce": "^3.7.1", "copy-webpack-plugin": "^13.0.1", - "eslint": "^9.36.0", + "eslint": "^9.39.4", "husky": "^9.1.7", "ts-loader": "^9.5.4", "typescript": "^5.9.3", - "webpack": "^5.102.0", + "webpack": "^5.105.4", "webpack-cli": "^6.0.1" } } diff --git a/scripts/changeset-enhanced.js b/scripts/changeset-enhanced.js index f6a6690..7eecbed 100644 --- a/scripts/changeset-enhanced.js +++ b/scripts/changeset-enhanced.js @@ -152,13 +152,14 @@ Delete sections you don't need. Save and close when done. // Read the content const content = fs.readFileSync(tempFile, 'utf-8').trim(); - // Clean up - fs.unlinkSync(tempFile); - return content; } catch (error) { console.log('Editor failed. Using simple text input instead.'); return null; + } finally { + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + } } } @@ -307,7 +308,7 @@ async function main() { execSync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { stdio: 'inherit' }); console.log('\nDone! Commit created.'); - console.log('Push to main to trigger auto-release.'); + console.log('Review the staged release plan separately; publishing is handled by the manual release workflow.'); } catch (error) { console.error(`Error: ${error.message}`); diff --git a/scripts/run-vscode-tests.mjs b/scripts/run-vscode-tests.mjs new file mode 100644 index 0000000..86a03cb --- /dev/null +++ b/scripts/run-vscode-tests.mjs @@ -0,0 +1,102 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { downloadAndUnzipVSCode } from '@vscode/test-electron'; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDirectory, '..'); +const compiledSuiteRoot = path.join(repoRoot, 'out', 'test', 'suite'); +const workspaceFolder = path.join(repoRoot, 'src', 'test', 'fixtures', 'workspace'); +const extensionTestsPath = path.join(repoRoot, 'node_modules', '@vscode', 'test-cli', 'out', 'runner.cjs'); +const vscodeTestRoot = path.join(repoRoot, '.vscode-test'); + +const testFiles = await collectTestFiles(compiledSuiteRoot); +if (testFiles.length === 0) { + throw new Error(`No compiled tests found under ${compiledSuiteRoot}`); +} + +await fs.mkdir(vscodeTestRoot, { recursive: true }); + +const runDirectory = await fs.mkdtemp(path.join(vscodeTestRoot, 'run-')); +const userDataDirectory = path.join(runDirectory, 'user-data'); +const extensionsDirectory = path.join(runDirectory, 'extensions'); + +await fs.mkdir(userDataDirectory, { recursive: true }); +await fs.mkdir(extensionsDirectory, { recursive: true }); + +const vscodeExecutablePath = await downloadAndUnzipVSCode('stable'); +const env = { + ...process.env, + VSCODE_TEST_OPTIONS: JSON.stringify({ + mochaOpts: { + ui: 'tdd', + timeout: 20000, + }, + colorDefault: !!process.stdout.isTTY, + preload: [], + files: testFiles, + }), +}; + +delete env.ELECTRON_RUN_AS_NODE; + +const args = [ + workspaceFolder, + '--no-sandbox', + '--disable-gpu-sandbox', + '--disable-updates', + '--skip-welcome', + '--skip-release-notes', + '--disable-workspace-trust', + `--user-data-dir=${userDataDirectory}`, + `--extensions-dir=${extensionsDirectory}`, + `--extensionTestsPath=${extensionTestsPath}`, + `--extensionDevelopmentPath=${repoRoot}`, +]; + +try { + const exitCode = await runTests(vscodeExecutablePath, args, env); + process.exitCode = exitCode; +} finally { + await fs.rm(runDirectory, { recursive: true, force: true }); +} + +async function collectTestFiles(directory) { + const entries = await fs.readdir(directory, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + files.push(...await collectTestFiles(entryPath)); + continue; + } + + if (entry.isFile() && entry.name.endsWith('.test.js')) { + files.push(entryPath); + } + } + + return files.sort((left, right) => left.localeCompare(right)); +} + +function runTests(executablePath, args, env) { + return new Promise((resolve, reject) => { + const child = spawn(executablePath, args, { + env, + stdio: 'inherit', + }); + + child.once('error', reject); + child.once('exit', (code, signal) => { + if (signal) { + reject(new Error(`VS Code tests terminated with signal ${signal}`)); + return; + } + + resolve(code ?? 1); + }); + }); +} \ No newline at end of file diff --git a/src/commands/annotation.ts b/src/commands/annotation.ts index 8fc8b87..c844ec5 100644 --- a/src/commands/annotation.ts +++ b/src/commands/annotation.ts @@ -4,23 +4,15 @@ */ import * as vscode from 'vscode'; -import { AnnotationManager } from '../managers'; import { AnnotationItem } from '../ui'; import { CommandContext } from './index'; -import { CopilotExporter } from '../copilotExporter'; - -/** - * Helper to convert Tag to string - */ -function tagToString(tag: string | { id: string }): string { - return typeof tag === 'string' ? tag : tag.id; -} export function registerAnnotationCommands( context: vscode.ExtensionContext, cmdContext: CommandContext ) { const { annotationManager, sidebarWebview, ANNOTATION_COLORS } = cmdContext; + const exportService = annotationManager.getExportService(); // Command: Add annotation to selected text const addAnnotationCommand = vscode.commands.registerTextEditorCommand( @@ -50,11 +42,15 @@ export function registerAnnotationCommands( let selectedTags: string[] = []; if (customTags.length > 0) { - const tagOptions = customTags.map(t => t.name); - selectedTags = await vscode.window.showQuickPick(tagOptions, { + const tagOptions = customTags.map(tag => ({ + label: tag.name, + value: tag.id, + })); + const selected = await vscode.window.showQuickPick(tagOptions, { placeHolder: 'Select tags (optional)', canPickMany: true - }) || []; + }); + selectedTags = selected?.map(tag => tag.value) || []; } // Get color via quick pick @@ -150,13 +146,16 @@ export function registerAnnotationCommands( let selectedTags: string[] = []; if (customTags.length > 0) { - const tagOptions = customTags.map(t => t.name); + const tagOptions = customTags.map(tag => ({ + label: tag.name, + value: tag.id, + })); const tags = await vscode.window.showQuickPick(tagOptions, { placeHolder: 'Select tags (optional)', canPickMany: true }); if (tags) { - selectedTags = tags; + selectedTags = tags.map(tag => tag.value); } } @@ -177,22 +176,28 @@ export function registerAnnotationCommands( ); if (action === 'Ask Copilot') { + if (!exportService.isCopilotEnabled()) { + vscode.window.showWarningMessage('Copilot integration is disabled in Annotative settings.'); + return; + } + const annotations = annotationManager.getAnnotationsForFile(editor.document.uri.fsPath); const newAnnotation = annotations[annotations.length - 1]; if (newAnnotation) { - const prompt = await CopilotExporter.formatAnnotationForCopilot(newAnnotation, { - contextLines: 5, - smartContext: true - }); + const prompt = await exportService.formatAnnotationForCopilot(newAnnotation); await vscode.env.clipboard.writeText(prompt); - try { - await vscode.commands.executeCommand('workbench.panel.chat.view.copilot.focus'); + const opened = await exportService.openCopilotChatIfConfigured(); + if (opened) { vscode.window.showInformationMessage('Paste into Copilot Chat (Ctrl+V)'); - } catch { - vscode.window.showInformationMessage('Context copied. Open Copilot Chat and paste.'); + } else { + vscode.window.showInformationMessage('Context copied. Open Copilot Chat and paste.', 'Open Chat').then(choice => { + if (choice === 'Open Chat') { + void exportService.openCopilotChatIfConfigured(true); + } + }); } } } @@ -249,16 +254,21 @@ export function registerAnnotationCommands( // Get updated tags via quick pick (multi-select) - user-defined only const customTags = annotationManager.getCustomTags(); - let tagsToUse = annotation.tags?.map((t) => tagToString(t)) || []; + const currentTags = annotation.tags || []; + let tagsToUse = [...currentTags]; if (customTags.length > 0) { - const tagOptions = customTags.map(t => t.name); + const tagOptions = customTags.map(tag => ({ + label: tag.name, + value: tag.id, + picked: currentTags.includes(tag.id), + })); const selectedTags = await vscode.window.showQuickPick(tagOptions, { placeHolder: 'Select tags (optional)', canPickMany: true }); if (selectedTags) { - tagsToUse = selectedTags; + tagsToUse = selectedTags.map(tag => tag.value); } } @@ -292,7 +302,7 @@ export function registerAnnotationCommands( // Show details in information message const tagsStr = annotation.tags && annotation.tags.length > 0 - ? annotation.tags.map(t => tagToString(t)).join(', ') + ? annotationManager.resolveTagLabels(annotation.tags).join(', ') : 'none'; const resolvedStr = annotation.resolved ? 'Resolved' : 'Open'; diff --git a/src/commands/bulk.ts b/src/commands/bulk.ts index f6c888a..6538bd3 100644 --- a/src/commands/bulk.ts +++ b/src/commands/bulk.ts @@ -4,23 +4,19 @@ */ import * as vscode from 'vscode'; -import { AnnotationManager } from '../managers'; import { AnnotationProvider } from '../ui'; import { CommandContext } from './index'; -/** - * Helper to convert Tag to string - */ -function tagToString(tag: string | { id: string }): string { - return typeof tag === 'string' ? tag : tag.id; -} - export function registerBulkCommands( context: vscode.ExtensionContext, cmdContext: CommandContext ) { const { annotationManager, annotationProvider, sidebarWebview, ANNOTATION_COLORS } = cmdContext; + if (!annotationProvider) { + return {}; + } + // Command: Bulk tag annotations const bulkTagCommand = vscode.commands.registerCommand( 'annotative.bulkTag', @@ -44,7 +40,10 @@ export function registerBulkCommands( return; } - const tagOptions = customTags.map(t => t.name); + const tagOptions = customTags.map(tag => ({ + label: tag.name, + value: tag.id, + })); const newTags = await vscode.window.showQuickPick(tagOptions, { placeHolder: `Add tags to ${selected.length} annotation(s)`, canPickMany: true @@ -52,8 +51,8 @@ export function registerBulkCommands( if (newTags && newTags.length > 0) { for (const annotation of selected) { - const updated = new Set((annotation.tags || []).map(t => tagToString(t))); - newTags.forEach(tag => updated.add(tag)); + const updated = new Set(annotation.tags || []); + newTags.forEach(tag => updated.add(tag.value)); await annotationManager.editAnnotation( annotation.id, annotation.filePath, @@ -137,12 +136,11 @@ export function registerBulkCommands( if (selectedColor) { for (const annotation of selected) { - const tagsStr = annotation.tags?.map(t => tagToString(t)); await annotationManager.editAnnotation( annotation.id, annotation.filePath, annotation.comment, - tagsStr, + annotation.tags, selectedColor.value ); } diff --git a/src/commands/export.ts b/src/commands/export.ts index 576b8d8..0f30ef9 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -4,10 +4,8 @@ */ import * as vscode from 'vscode'; -import { AnnotationManager } from '../managers'; import { AnnotationItem } from '../ui'; import { CommandContext } from './index'; -import { CopilotExporter } from '../copilotExporter'; /** * Helper to convert Tag to string @@ -21,6 +19,30 @@ export function registerExportCommands( cmdContext: CommandContext ) { const { annotationManager } = cmdContext; + const exportService = annotationManager.getExportService(); + + async function promptToOpenCopilotChat(message: string): Promise { + const opened = await exportService.openCopilotChatIfConfigured(); + if (opened) { + vscode.window.showInformationMessage(`${message} Paste into Copilot Chat (Ctrl+V)`, 'Got it'); + return; + } + + vscode.window.showInformationMessage(message, 'Open Chat').then(action => { + if (action === 'Open Chat') { + void exportService.openCopilotChatIfConfigured(true); + } + }); + } + + function ensureCopilotEnabled(): boolean { + if (exportService.isCopilotEnabled()) { + return true; + } + + vscode.window.showWarningMessage('Copilot integration is disabled in Annotative settings.'); + return false; + } // Command: Export annotations to clipboard const exportAnnotationsCommand = vscode.commands.registerCommand( @@ -57,16 +79,19 @@ export function registerExportCommands( const exportForCopilotCommand = vscode.commands.registerCommand( 'annotative.exportForCopilot', async () => { + if (!ensureCopilotEnabled()) { + return; + } + const annotations = annotationManager.getAllAnnotations(); if (annotations.length === 0) { vscode.window.showInformationMessage('No annotations to export'); return; } - const markdown = CopilotExporter.exportForCopilotChat(annotations); + const prepared = exportService.prepareCopilotExport(annotations); - // Copy to clipboard - await vscode.env.clipboard.writeText(markdown); + await vscode.env.clipboard.writeText(prepared.content); const action = await vscode.window.showInformationMessage( `Exported ${annotations.length} annotation(s). Paste into Copilot Chat.`, @@ -75,17 +100,16 @@ export function registerExportCommands( ); if (action === 'Save to Workspace') { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (workspaceRoot) { - await CopilotExporter.exportToWorkspace(annotations, workspaceRoot); + try { + await exportService.saveCopilotExport(prepared.annotations); vscode.window.showInformationMessage('Saved to .copilot/annotations'); - } else { + } catch { vscode.window.showWarningMessage('No workspace folder'); } } else if (action === 'View Output') { const doc = await vscode.workspace.openTextDocument({ - content: markdown, - language: 'markdown' + content: prepared.content, + language: prepared.language }); await vscode.window.showTextDocument(doc); } @@ -96,6 +120,10 @@ export function registerExportCommands( const exportSelectedForCopilotCommand = vscode.commands.registerCommand( 'annotative.exportSelectedForCopilot', async (selectedIds: string[]) => { + if (!ensureCopilotEnabled()) { + return; + } + if (!selectedIds || selectedIds.length === 0) { vscode.window.showInformationMessage('No annotations selected'); return; @@ -109,10 +137,8 @@ export function registerExportCommands( return; } - const markdown = CopilotExporter.exportForCopilotChat(selectedAnnotations); - - // Copy to clipboard - await vscode.env.clipboard.writeText(markdown); + const prepared = exportService.prepareCopilotExport(selectedAnnotations); + await vscode.env.clipboard.writeText(prepared.content); const action = await vscode.window.showInformationMessage( `Exported ${selectedAnnotations.length} annotation(s). Paste into Copilot Chat.`, @@ -121,17 +147,16 @@ export function registerExportCommands( ); if (action === 'Save to Workspace') { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (workspaceRoot) { - await CopilotExporter.exportToWorkspace(selectedAnnotations, workspaceRoot); + try { + await exportService.saveCopilotExport(prepared.annotations); vscode.window.showInformationMessage('Saved to .copilot/annotations'); - } else { + } catch { vscode.window.showWarningMessage('No workspace folder'); } } else if (action === 'View Output') { const doc = await vscode.workspace.openTextDocument({ - content: markdown, - language: 'markdown' + content: prepared.content, + language: prepared.language }); await vscode.window.showTextDocument(doc); } @@ -142,35 +167,17 @@ export function registerExportCommands( const askCopilotAboutAnnotationCommand = vscode.commands.registerCommand( 'annotative.askCopilotAboutAnnotation', async (item: AnnotationItem) => { + if (!ensureCopilotEnabled()) { + return; + } + const annotation = item.annotation; try { - // Format annotation for Copilot - const prompt = await CopilotExporter.formatAnnotationForCopilot(annotation, { - contextLines: 5, - smartContext: true - }); - - // Copy to clipboard + const prompt = await exportService.formatAnnotationForCopilot(annotation); await vscode.env.clipboard.writeText(prompt); - // Try to open Copilot Chat - try { - await vscode.commands.executeCommand('workbench.panel.chat.view.copilot.focus'); - vscode.window.showInformationMessage( - 'Context copied. Paste into Copilot Chat (Ctrl+V)', - 'Got it' - ); - } catch (err) { - vscode.window.showInformationMessage( - 'Context copied. Open Copilot Chat and paste.', - 'Open Chat' - ).then(action => { - if (action === 'Open Chat') { - vscode.commands.executeCommand('workbench.panel.chat.view.copilot.focus'); - } - }); - } + await promptToOpenCopilotChat('Context copied. Open Copilot Chat and paste.'); } catch (error) { vscode.window.showErrorMessage(`Error: ${error}`); } @@ -181,10 +188,14 @@ export function registerExportCommands( const copyAsCopilotContextCommand = vscode.commands.registerCommand( 'annotative.copyAsCopilotContext', async (item: AnnotationItem) => { + if (!ensureCopilotEnabled()) { + return; + } + const annotation = item.annotation; try { - const quickContext = await CopilotExporter.formatAsQuickContext(annotation); + const quickContext = await exportService.formatQuickCopilotContext(annotation); await vscode.env.clipboard.writeText(quickContext); vscode.window.showInformationMessage('Copied to clipboard'); @@ -198,6 +209,10 @@ export function registerExportCommands( const exportByIntentCommand = vscode.commands.registerCommand( 'annotative.exportByIntent', async () => { + if (!ensureCopilotEnabled()) { + return; + } + const intent = await vscode.window.showQuickPick([ { label: 'Code Review', value: 'review', description: 'Unresolved annotations' }, { label: 'Bug Fixes', value: 'bugs', description: 'Bugs and security issues' }, @@ -212,12 +227,12 @@ export function registerExportCommands( } const annotations = annotationManager.getAllAnnotations(); - const exported = CopilotExporter.exportByIntent( + const prepared = exportService.prepareCopilotIntentExport( annotations, intent.value as 'review' | 'bugs' | 'optimization' | 'documentation' ); - await vscode.env.clipboard.writeText(exported); + await vscode.env.clipboard.writeText(prepared.content); const action = await vscode.window.showInformationMessage( `Exported ${intent.label} annotations`, @@ -227,19 +242,16 @@ export function registerExportCommands( if (action === 'View Output') { const doc = await vscode.workspace.openTextDocument({ - content: exported, - language: 'markdown' + content: prepared.content, + language: prepared.language }); await vscode.window.showTextDocument(doc); } else if (action === 'Save to File') { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (workspaceRoot) { - await CopilotExporter.exportToWorkspace( - annotations.filter(a => !a.resolved), - workspaceRoot, - intent.value - ); + try { + await exportService.saveCopilotExport(prepared.annotations, intent.value); vscode.window.showInformationMessage('Saved to .copilot/annotations'); + } catch { + vscode.window.showWarningMessage('No workspace folder'); } } } @@ -283,15 +295,13 @@ export function registerExportCommands( return; } - const exported = CopilotExporter.exportForAI(annotations, { - format: format.value as any, - includeResolved: includeResolved.value, - contextLines: 5, - includeImports: false, - includeFunction: false - }); + const prepared = exportService.prepareAIExport( + annotations, + format.value as 'copilot' | 'chatgpt' | 'claude' | 'generic', + includeResolved.value + ); - await vscode.env.clipboard.writeText(exported); + await vscode.env.clipboard.writeText(prepared.content); vscode.window.showInformationMessage( `Exported ${annotations.length} for ${format.label}`, @@ -299,8 +309,8 @@ export function registerExportCommands( ).then(action => { if (action === 'View Output') { vscode.workspace.openTextDocument({ - content: exported, - language: format.value === 'claude' ? 'xml' : 'markdown' + content: prepared.content, + language: prepared.language }).then(doc => vscode.window.showTextDocument(doc)); } }); @@ -311,6 +321,10 @@ export function registerExportCommands( const batchAIReviewCommand = vscode.commands.registerCommand( 'annotative.batchAIReview', async () => { + if (!ensureCopilotEnabled()) { + return; + } + const allAnnotations = annotationManager.getAllAnnotations(); const unresolved = allAnnotations.filter(a => !a.resolved); @@ -352,10 +366,7 @@ export function registerExportCommands( }); // Format each annotation - const formatted = await CopilotExporter.formatAnnotationForCopilot(annotation, { - contextLines: 5, - smartContext: true - }); + const formatted = await exportService.formatAnnotationForCopilot(annotation); fullReport += formatted; fullReport += `\n\n---\n\n`; @@ -400,16 +411,16 @@ export function registerExportCommands( }); await vscode.window.showTextDocument(doc); } else if (action === 'Open Copilot Chat') { - try { - await vscode.commands.executeCommand('workbench.panel.chat.view.copilot.focus'); - } catch { + const opened = await exportService.openCopilotChatIfConfigured(true); + if (!opened) { vscode.window.showInformationMessage('Open Copilot Chat and paste report'); } } else if (action === 'Save to File') { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (workspaceRoot) { - await CopilotExporter.exportToWorkspace(toReview, workspaceRoot, 'batch-review'); + try { + await exportService.saveCopilotExport(toReview, 'batch-review'); vscode.window.showInformationMessage('Saved to .copilot/annotations'); + } catch { + vscode.window.showWarningMessage('No workspace folder'); } } }); diff --git a/src/commands/filters.ts b/src/commands/filters.ts index cd0229f..e864f03 100644 --- a/src/commands/filters.ts +++ b/src/commands/filters.ts @@ -4,8 +4,6 @@ */ import * as vscode from 'vscode'; -import { AnnotationManager } from '../managers'; -import { AnnotationProvider } from '../ui'; import { Annotation } from '../types'; import { CommandContext } from './index'; @@ -13,13 +11,13 @@ export function registerFilterCommands( context: vscode.ExtensionContext, cmdContext: CommandContext ) { - const { annotationManager, annotationProvider } = cmdContext; + const { annotationManager, sidebarWebview } = cmdContext; // Command: Refresh annotations view const refreshCommand = vscode.commands.registerCommand( 'annotative.refresh', () => { - annotationProvider.refresh(); + sidebarWebview.refreshAnnotations(); const activeEditor = vscode.window.activeTextEditor; if (activeEditor) { annotationManager.updateDecorations(activeEditor); @@ -31,7 +29,7 @@ export function registerFilterCommands( const filterByStatusCommand = vscode.commands.registerCommand( 'annotative.filterByStatus', async () => { - const currentFilter = annotationProvider.getFilterStatus(); + const currentFilter = sidebarWebview.getFilterState().status; const options = [ { label: 'All Annotations', value: 'all' as const, description: currentFilter === 'all' ? '(current)' : '' }, { label: 'Unresolved Only', value: 'unresolved' as const, description: currentFilter === 'unresolved' ? '(current)' : '' }, @@ -43,7 +41,7 @@ export function registerFilterCommands( }); if (selected) { - annotationProvider.setFilterStatus(selected.value); + sidebarWebview.setFilterState({ status: selected.value }); vscode.window.showInformationMessage(`Filter: ${selected.label}`); } } @@ -54,12 +52,12 @@ export function registerFilterCommands( 'annotative.filterByTag', async () => { const allTags = annotationManager.getAllTags(); - const currentTag = annotationProvider.getFilterTag(); + const currentTag = sidebarWebview.getFilterState().tag; const options = [ { label: 'All Tags', value: 'all' as const, description: currentTag === 'all' ? '(current)' : '' }, ...allTags.map(tag => ({ - label: tag, + label: annotationManager.resolveTagLabel(tag), value: tag, description: currentTag === tag ? '(current)' : '' })) @@ -70,7 +68,7 @@ export function registerFilterCommands( }); if (selected) { - annotationProvider.setFilterTag(selected.value); + sidebarWebview.setFilterState({ tag: selected.value }); vscode.window.showInformationMessage(`Filter by tag: ${selected.label}`); } } @@ -83,11 +81,11 @@ export function registerFilterCommands( const query = await vscode.window.showInputBox({ prompt: 'Search annotations by comment, code, author, or tag', placeHolder: 'Enter search query...', - value: annotationProvider.getSearchQuery() + value: sidebarWebview.getFilterState().search }); if (query !== undefined) { - annotationProvider.setSearchQuery(query); + sidebarWebview.setFilterState({ search: query.trim() }); if (query) { vscode.window.showInformationMessage(`Search: "${query}"`); } else { @@ -101,7 +99,7 @@ export function registerFilterCommands( const clearFiltersCommand = vscode.commands.registerCommand( 'annotative.clearFilters', () => { - annotationProvider.clearFilters(); + sidebarWebview.clearFilters(); vscode.window.showInformationMessage('Filters cleared'); } ); @@ -112,11 +110,13 @@ export function registerFilterCommands( async (annotation: Annotation) => { try { const document = await vscode.workspace.openTextDocument(vscode.Uri.file(annotation.filePath)); + await annotationManager.rebaseAnnotationsForDocument(document); const editor = await vscode.window.showTextDocument(document); + const resolvedAnnotation = annotationManager.getAnnotation(annotation.id, annotation.filePath) || annotation; // Reveal and select the annotated range - editor.revealRange(annotation.range, vscode.TextEditorRevealType.InCenter); - editor.selection = new vscode.Selection(annotation.range.start, annotation.range.end); + editor.revealRange(resolvedAnnotation.range, vscode.TextEditorRevealType.InCenter); + editor.selection = new vscode.Selection(resolvedAnnotation.range.start, resolvedAnnotation.range.end); } catch (error) { vscode.window.showErrorMessage(`Cannot open: ${annotation.filePath}`); } @@ -127,10 +127,12 @@ export function registerFilterCommands( const toggleGroupByCommand = vscode.commands.registerCommand( 'annotative.toggleGroupBy', async () => { + const currentGroupBy = sidebarWebview.getFilterState().groupBy; const options = [ - { label: 'By File', value: 'file' as const }, - { label: 'By Tag', value: 'tag' as const }, - { label: 'By Status', value: 'status' as const } + { label: 'By File', value: 'file' as const, description: currentGroupBy === 'file' ? '(current)' : '' }, + { label: 'By Tag', value: 'tag' as const, description: currentGroupBy === 'tag' ? '(current)' : '' }, + { label: 'By Status', value: 'status' as const, description: currentGroupBy === 'status' ? '(current)' : '' }, + { label: 'By Folder', value: 'folder' as const, description: currentGroupBy === 'folder' ? '(current)' : '' } ]; const selected = await vscode.window.showQuickPick(options, { @@ -138,7 +140,7 @@ export function registerFilterCommands( }); if (selected) { - annotationProvider.setGroupBy(selected.value); + sidebarWebview.setFilterState({ groupBy: selected.value }); vscode.window.showInformationMessage(`Grouped ${selected.label.toLowerCase()}`); } } diff --git a/src/commands/index.ts b/src/commands/index.ts index b6bf7fe..8b9ba7a 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -5,13 +5,12 @@ import * as vscode from 'vscode'; import { AnnotationManager } from '../managers'; -import { AnnotationProvider, AnnotationItem, SidebarWebview } from '../ui'; -import { Annotation } from '../types'; +import { AnnotationProvider, SidebarWebview } from '../ui'; export type CommandContext = { annotationManager: AnnotationManager; - annotationProvider: AnnotationProvider; sidebarWebview: SidebarWebview; + annotationProvider?: AnnotationProvider; ANNOTATION_COLORS: Array<{ label: string; value: string }>; }; @@ -19,7 +18,6 @@ export type CommandContext = { export { registerAnnotationCommands } from './annotation'; export { registerExportCommands } from './export'; export { registerFilterCommands } from './filters'; -export { registerBulkCommands } from './bulk'; export { registerNavigationCommands } from './navigation'; export { registerSidebarCommands } from './sidebar'; export { registerTagCommands } from './tags'; diff --git a/src/commands/sidebar.ts b/src/commands/sidebar.ts index 2cf2d3d..e9d0880 100644 --- a/src/commands/sidebar.ts +++ b/src/commands/sidebar.ts @@ -6,6 +6,14 @@ import * as vscode from 'vscode'; import { CommandContext } from './index'; +function getStorageInitializationErrorMessage(error: unknown): string { + if (error instanceof Error && error.message === 'No workspace folder open') { + return 'Open a folder or workspace before initializing Annotative storage.'; + } + + return `Failed to initialize project storage: ${error}`; +} + export function registerSidebarCommands( context: vscode.ExtensionContext, cmdContext: CommandContext @@ -38,34 +46,18 @@ export function registerSidebarCommands( return; } - // Ask about migration - const migrateChoice = await vscode.window.showQuickPick([ - { label: 'Create new storage', description: 'Start fresh', value: false }, - { label: 'Migrate existing', description: 'Copy current annotations', value: true } - ], { - placeHolder: 'Initialize .annotative folder' - }); - - if (!migrateChoice) { - return; - } - try { - const created = await annotationManager.initializeProjectStorage(migrateChoice.value); + const created = await annotationManager.initializeProjectStorage(); if (created) { - vscode.window.showInformationMessage( - migrateChoice.value - ? 'Project storage initialized with existing data.' - : 'Project storage initialized.' - ); + vscode.window.showInformationMessage('Project storage initialized.'); } else { vscode.window.showInformationMessage('Switched to project storage.'); } sidebarWebview.refreshAnnotations(); } catch (error) { - vscode.window.showErrorMessage(`Failed to initialize: ${error}`); + vscode.window.showErrorMessage(getStorageInitializationErrorMessage(error)); } } ); diff --git a/src/commands/tags.ts b/src/commands/tags.ts index ce78233..1bedca5 100644 --- a/src/commands/tags.ts +++ b/src/commands/tags.ts @@ -7,13 +7,6 @@ import * as vscode from 'vscode'; import { AnnotationItem } from '../ui'; import { CommandContext } from './index'; -/** - * Helper to convert Tag to string - */ -function tagToString(tag: string | { id: string }): string { - return typeof tag === 'string' ? tag : tag.id; -} - export function registerTagCommands( context: vscode.ExtensionContext, cmdContext: CommandContext @@ -41,7 +34,7 @@ export function registerTagCommands( } // Filter out tags already on the annotation - const currentTags = annotation.tags?.map((t) => tagToString(t)) || []; + const currentTags = annotation.tags || []; const availableTags = customTags.filter(tag => !currentTags.includes(tag.id)); if (availableTags.length === 0) { @@ -85,7 +78,7 @@ export function registerTagCommands( const annotation = item.annotation; // Get current tags - const currentTags = annotation.tags?.map((t) => tagToString(t)) || []; + const currentTags = annotation.tags || []; if (currentTags.length === 0) { vscode.window.showInformationMessage('No tags to remove.'); @@ -95,9 +88,16 @@ export function registerTagCommands( // If tag not specified, let user select which tag to remove let selectedTag = tagToRemove; if (!selectedTag) { - selectedTag = await vscode.window.showQuickPick(currentTags, { - placeHolder: 'Select tag to remove' - }); + const selected = await vscode.window.showQuickPick( + currentTags.map(tagId => ({ + label: annotationManager.resolveTagLabel(tagId), + value: tagId, + })), + { + placeHolder: 'Select tag to remove' + } + ); + selectedTag = selected?.value; } if (!selectedTag) { @@ -115,7 +115,7 @@ export function registerTagCommands( ); sidebarWebview.refreshAnnotations(); - vscode.window.showInformationMessage(`Tag removed: ${selectedTag}`); + vscode.window.showInformationMessage(`Tag removed: ${annotationManager.resolveTagLabel(selectedTag)}`); } ); @@ -139,7 +139,12 @@ export function registerTagCommands( return; } - const tagOptions = customTags.map(t => t.name); + const currentTags = annotation.tags || []; + const tagOptions = customTags.map(tag => ({ + label: tag.name, + value: tag.id, + picked: currentTags.includes(tag.id), + })); // Let user select tags (multi-select) const selectedTags = await vscode.window.showQuickPick(tagOptions, { @@ -156,7 +161,7 @@ export function registerTagCommands( annotation.id, annotation.filePath, annotation.comment, - selectedTags, + selectedTags.map(tag => tag.value), annotation.color ); diff --git a/src/copilotExporter.ts b/src/copilotExporter.ts index 442fd5b..561128b 100644 --- a/src/copilotExporter.ts +++ b/src/copilotExporter.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; import { Annotation, CopilotExportOptions, ExportOptions, Tag, AnnotationTag } from './types'; +import { getRelativePathForFile, groupAnnotationsByFile } from './managers/exportSupport'; export enum CopilotExportFormat { Chat = 'chat', // Optimized for pasting into Copilot Chat @@ -14,6 +15,40 @@ export enum CopilotExportFormat { export class CopilotExporter { + public static filterByIntent( + annotations: Annotation[], + intent: 'review' | 'bugs' | 'optimization' | 'documentation' + ): { annotations: Annotation[]; title: string } { + switch (intent) { + case 'review': + return { + annotations: annotations.filter(annotation => !annotation.resolved), + title: 'Code Review', + }; + case 'bugs': + return { + annotations: annotations.filter(annotation => + annotation.tags?.some(tag => ['bug', 'security'].includes(this.tagToString(tag).toLowerCase())) + ), + title: 'Bug Fixes and Security Issues', + }; + case 'optimization': + return { + annotations: annotations.filter(annotation => + annotation.tags?.some(tag => ['performance', 'optimization'].includes(this.tagToString(tag).toLowerCase())) + ), + title: 'Performance Optimization', + }; + case 'documentation': + return { + annotations: annotations.filter(annotation => + annotation.tags?.some(tag => ['docs', 'documentation', 'question'].includes(this.tagToString(tag).toLowerCase())) + ), + title: 'Documentation Needs', + }; + } + } + /** * Convert a Tag to its string representation */ @@ -38,10 +73,9 @@ export class CopilotExporter { annotation: Annotation, options: CopilotExportOptions = {} ): Promise { - const relativePath = vscode.workspace.asRelativePath(annotation.filePath); + const relativePath = getRelativePathForFile(annotation.filePath); const lineStart = annotation.range.start.line + 1; const lineEnd = annotation.range.end.line + 1; - const contextLines = options.contextLines ?? 5; let output = `# Code Review Context from Annotative\n\n`; output += `## File: \`${relativePath}\` (Lines ${lineStart}-${lineEnd})\n\n`; @@ -79,7 +113,7 @@ export class CopilotExporter { * Copy annotation as Copilot context (compact format) */ public static async formatAsQuickContext(annotation: Annotation): Promise { - const relativePath = vscode.workspace.asRelativePath(annotation.filePath); + const relativePath = getRelativePathForFile(annotation.filePath); const lineStart = annotation.range.start.line + 1; const lineEnd = annotation.range.end.line + 1; const languageId = await this.getLanguageId(annotation.filePath); @@ -105,35 +139,8 @@ export class CopilotExporter { annotations: Annotation[], intent: 'review' | 'bugs' | 'optimization' | 'documentation' ): string { - let filtered: Annotation[]; - let title: string; - - switch (intent) { - case 'review': - filtered = annotations.filter(a => !a.resolved); - title = 'Code Review'; - break; - case 'bugs': - filtered = annotations.filter(a => - a.tags?.some(t => ['bug', 'security'].includes(this.tagToString(t).toLowerCase())) - ); - title = 'Bug Fixes and Security Issues'; - break; - case 'optimization': - filtered = annotations.filter(a => - a.tags?.some(t => ['performance', 'optimization'].includes(this.tagToString(t).toLowerCase())) - ); - title = 'Performance Optimization'; - break; - case 'documentation': - filtered = annotations.filter(a => - a.tags?.some(t => ['docs', 'documentation', 'question'].includes(this.tagToString(t).toLowerCase())) - ); - title = 'Documentation Needs'; - break; - } - - return this.exportForCopilotChat(filtered, title); + const filtered = this.filterByIntent(annotations, intent); + return this.exportForCopilotChat(filtered.annotations, filtered.title); } /** @@ -163,7 +170,7 @@ export class CopilotExporter { const byFile = this.groupByFile(annotations); byFile.forEach((fileAnnotations, filePath) => { - const relativePath = vscode.workspace.asRelativePath(filePath); + const relativePath = getRelativePathForFile(filePath); output += `## File: \`${relativePath}\`\n\n`; fileAnnotations.forEach((annotation, idx) => { @@ -203,7 +210,7 @@ export class CopilotExporter { const byFile = this.groupByFile(annotations); byFile.forEach((fileAnnotations, filePath) => { - const relativePath = vscode.workspace.asRelativePath(filePath); + const relativePath = getRelativePathForFile(filePath); output += `\n`; fileAnnotations.forEach((annotation) => { @@ -295,7 +302,12 @@ export class CopilotExporter { for (let i = startLine; i <= endLine; i++) { const line = document.lineAt(i).text; const lineNumber = i + 1; - const marker = (i >= annotation.range.start.line && i <= annotation.range.end.line) ? '> ' : ' '; + const isSelectedLine = i >= annotation.range.start.line && i <= annotation.range.end.line; + if (!options.includeImports && !isSelectedLine && this.isImportLine(line)) { + continue; + } + + const marker = isSelectedLine ? '> ' : ' '; context += `${marker}${lineNumber}: ${line}\n`; } @@ -362,7 +374,7 @@ export class CopilotExporter { const byFile = this.groupByFile(annotations); byFile.forEach((fileAnnotations, filePath) => { - const relativePath = vscode.workspace.asRelativePath(filePath); + const relativePath = getRelativePathForFile(filePath); const unresolvedCount = fileAnnotations.filter(a => !a.resolved).length; output += `## \`${relativePath}\`\n\n`; @@ -402,7 +414,7 @@ export class CopilotExporter { output += ` \n`; annotations.forEach(annotation => { - const relativePath = vscode.workspace.asRelativePath(annotation.filePath); + const relativePath = getRelativePathForFile(annotation.filePath); const lineStart = annotation.range.start.line + 1; const lineEnd = annotation.range.end.line + 1; @@ -460,7 +472,7 @@ export class CopilotExporter { const byFile = this.groupByFile(annotations); byFile.forEach((fileAnnotations, filePath) => { - const relativePath = vscode.workspace.asRelativePath(filePath); + const relativePath = getRelativePathForFile(filePath); const safeName = relativePath.replace(/[\/\\:*?"<>|]/g, '_'); const annotationPath = path.join(sessionDir, `${safeName}.md`); @@ -482,7 +494,7 @@ export class CopilotExporter { let output = ''; annotations.forEach((annotation, index) => { - const relativePath = vscode.workspace.asRelativePath(annotation.filePath); + const relativePath = getRelativePathForFile(annotation.filePath); const lineStart = annotation.range.start.line + 1; const lineEnd = annotation.range.end.line + 1; const lineRange = lineStart === lineEnd ? `L${lineStart}` : `L${lineStart}-${lineEnd}`; @@ -510,7 +522,7 @@ export class CopilotExporter { const byFile = this.groupByFile(annotations); byFile.forEach((fileAnnotations, filePath) => { - const relativePath = vscode.workspace.asRelativePath(filePath); + const relativePath = getRelativePathForFile(filePath); output += `## File: \`${relativePath}\`\n\n`; @@ -536,21 +548,12 @@ export class CopilotExporter { // Helper methods private static groupByFile(annotations: Annotation[]): Map { - const grouped = new Map(); - - annotations.forEach(annotation => { - if (!grouped.has(annotation.filePath)) { - grouped.set(annotation.filePath, []); - } - grouped.get(annotation.filePath)!.push(annotation); - }); - - // Sort annotations within each file by line number - grouped.forEach(fileAnnotations => { - fileAnnotations.sort((a, b) => a.range.start.line - b.range.start.line); - }); + return groupAnnotationsByFile(annotations); + } - return grouped; + private static isImportLine(line: string): boolean { + const trimmedLine = line.trim(); + return /^(import\s|export\s+\{.*\}\s+from\s|const\s+.+?=\s*require\(|from\s+.+\s+import\s)/.test(trimmedLine); } private static escapeXml(text: string): string { @@ -571,7 +574,7 @@ export class CopilotExporter { const byFile = this.groupByFile(annotations); byFile.forEach((fileAnnotations, filePath) => { - const relativePath = vscode.workspace.asRelativePath(filePath); + const relativePath = getRelativePathForFile(filePath); const unresolvedCount = fileAnnotations.filter(a => !a.resolved).length; content += `- \`${relativePath}\` (${unresolvedCount} unresolved, ${fileAnnotations.length} total)\n`; }); diff --git a/src/extension.ts b/src/extension.ts index 782c545..254d835 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,12 +1,11 @@ import * as vscode from 'vscode'; import { AnnotationManager } from './managers'; -import { AnnotationProvider, SidebarWebview } from './ui'; +import { SidebarWebview } from './ui'; import { registerChatParticipant, registerChatVariableIfAvailable } from './copilotChatParticipant'; import { registerAnnotationCommands, registerExportCommands, registerFilterCommands, - registerBulkCommands, registerNavigationCommands, registerSidebarCommands, registerTagCommands, @@ -26,13 +25,11 @@ const ANNOTATION_COLORS = [ ]; let annotationManager: AnnotationManager; -let annotationProvider: AnnotationProvider; let sidebarWebview: SidebarWebview; export function activate(context: vscode.ExtensionContext) { // Initialize core managers annotationManager = new AnnotationManager(context); - annotationProvider = new AnnotationProvider(annotationManager); sidebarWebview = new SidebarWebview(context.extensionUri, annotationManager); // Register sidebar webview provider @@ -51,17 +48,17 @@ export function activate(context: vscode.ExtensionContext) { }) ); - // Register Copilot Chat integration - context.subscriptions.push(registerChatParticipant(context, annotationManager)); - const chatVariable = registerChatVariableIfAvailable(context, annotationManager); - if (chatVariable) { - context.subscriptions.push(chatVariable); + if (vscode.workspace.getConfiguration('annotative').get('copilot.enabled', true)) { + context.subscriptions.push(registerChatParticipant(context, annotationManager)); + const chatVariable = registerChatVariableIfAvailable(context, annotationManager); + if (chatVariable) { + context.subscriptions.push(chatVariable); + } } // Create command context const cmdContext: CommandContext = { annotationManager, - annotationProvider, sidebarWebview, ANNOTATION_COLORS }; @@ -71,7 +68,6 @@ export function activate(context: vscode.ExtensionContext) { ...Object.values(registerAnnotationCommands(context, cmdContext)), ...Object.values(registerExportCommands(context, cmdContext)), ...Object.values(registerFilterCommands(context, cmdContext)), - ...Object.values(registerBulkCommands(context, cmdContext)), ...Object.values(registerNavigationCommands(context, cmdContext)), ...Object.values(registerSidebarCommands(context, cmdContext)), ...Object.values(registerTagCommands(context, cmdContext)) @@ -86,6 +82,34 @@ export function activate(context: vscode.ExtensionContext) { }) ); + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument(document => { + void annotationManager.rebaseAnnotationsForDocument(document).then(changed => { + if (changed) { + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor && activeEditor.document.uri.toString() === document.uri.toString()) { + annotationManager.updateDecorations(activeEditor); + } + } + }); + }) + ); + + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument(event => { + void annotationManager.rebaseAnnotationsForDocument(event.document).then(changed => { + if (changed) { + const visibleEditor = vscode.window.visibleTextEditors.find( + editor => editor.document.uri.toString() === event.document.uri.toString() + ); + if (visibleEditor) { + annotationManager.updateDecorations(visibleEditor); + } + } + }); + }) + ); + if (vscode.window.activeTextEditor) { annotationManager.updateDecorations(vscode.window.activeTextEditor); } diff --git a/src/managers/annotationAnchors.ts b/src/managers/annotationAnchors.ts new file mode 100644 index 0000000..1667e2e --- /dev/null +++ b/src/managers/annotationAnchors.ts @@ -0,0 +1,474 @@ +import * as vscode from 'vscode'; +import { Annotation, AnnotationAnchor, StoredRange } from '../types'; + +const ANCHOR_CONTEXT_CHARS = 48; +const MIN_CONTEXT_CHARS = 6; + +interface TextIndex { + lineOffsets: number[]; + normalizedText: string; + normalizedOffsets: number[]; +} + +interface CandidateMatch { + startOffset: number; + endOffset: number; + score: number; +} + +export interface ReattachmentResult { + range: vscode.Range; + text: string; + anchor?: AnnotationAnchor; + changed: boolean; + reattached: boolean; +} + +export function captureAnnotationAnchor(documentText: string, range: vscode.Range): AnnotationAnchor | undefined { + const index = buildTextIndex(documentText); + const offsets = clampRangeOffsets(range, documentText, index.lineOffsets); + if (offsets.startOffset >= offsets.endOffset) { + return undefined; + } + + const selectedText = documentText.slice(offsets.startOffset, offsets.endOffset); + const prefixContext = documentText.slice(Math.max(0, offsets.startOffset - ANCHOR_CONTEXT_CHARS), offsets.startOffset); + const suffixContext = documentText.slice(offsets.endOffset, Math.min(documentText.length, offsets.endOffset + ANCHOR_CONTEXT_CHARS)); + + return { + selectedText, + prefixContext, + suffixContext, + selectedTextHash: hashString(selectedText), + normalizedTextHash: hashString(normalizeSnippet(selectedText)), + contextHash: hashString(`${normalizeSnippet(prefixContext)}|${normalizeSnippet(suffixContext)}`), + }; +} + +export function reattachAnnotation(annotation: Annotation, documentText: string): ReattachmentResult { + const index = buildTextIndex(documentText); + const fallbackOffsets = clampStoredRange(annotation.range, documentText, index.lineOffsets); + const fallbackRange = createRangeFromOffsets(fallbackOffsets.startOffset, fallbackOffsets.endOffset, index.lineOffsets); + const fallbackText = documentText.slice(fallbackOffsets.startOffset, fallbackOffsets.endOffset); + const fallbackAnchor = captureAnnotationAnchor(documentText, fallbackRange); + + if (!annotation.anchor?.selectedText) { + return { + range: fallbackRange, + text: fallbackText, + anchor: fallbackAnchor, + changed: !annotation.range.isEqual(fallbackRange) || annotation.text !== fallbackText || !anchorsEqual(annotation.anchor, fallbackAnchor), + reattached: false, + }; + } + + const anchor = annotation.anchor; + const exactMatch = findExactAnchorMatch(anchor, documentText, index, fallbackOffsets.startOffset); + if (exactMatch) { + return buildResolvedResult(annotation, documentText, index.lineOffsets, exactMatch.startOffset, exactMatch.endOffset, true); + } + + const contextualMatch = findContextualAnchorMatch(anchor, documentText, index, fallbackOffsets.startOffset); + if (contextualMatch) { + return buildResolvedResult(annotation, documentText, index.lineOffsets, contextualMatch.startOffset, contextualMatch.endOffset, true); + } + + return { + range: fallbackRange, + text: fallbackText, + anchor: fallbackAnchor, + changed: !annotation.range.isEqual(fallbackRange) || annotation.text !== fallbackText || !anchorsEqual(annotation.anchor, fallbackAnchor), + reattached: false, + }; +} + +function buildResolvedResult( + annotation: Annotation, + documentText: string, + lineOffsets: number[], + startOffset: number, + endOffset: number, + reattached: boolean, +): ReattachmentResult { + const range = createRangeFromOffsets(startOffset, endOffset, lineOffsets); + const text = documentText.slice(startOffset, endOffset); + const anchor = captureAnnotationAnchor(documentText, range); + + return { + range, + text, + anchor, + changed: !annotation.range.isEqual(range) || annotation.text !== text || !anchorsEqual(annotation.anchor, anchor), + reattached, + }; +} + +function findExactAnchorMatch( + anchor: AnnotationAnchor, + documentText: string, + index: TextIndex, + originalStartOffset: number, +): CandidateMatch | undefined { + const normalizedNeedle = normalizeSnippet(anchor.selectedText); + if (!normalizedNeedle) { + return undefined; + } + + const prefix = normalizeSnippet(anchor.prefixContext); + const suffix = normalizeSnippet(anchor.suffixContext); + const candidates: CandidateMatch[] = []; + let searchIndex = 0; + + while (searchIndex <= index.normalizedText.length) { + const matchIndex = index.normalizedText.indexOf(normalizedNeedle, searchIndex); + if (matchIndex === -1) { + break; + } + + const startOffset = index.normalizedOffsets[matchIndex] ?? 0; + const lastCharOffset = index.normalizedOffsets[matchIndex + normalizedNeedle.length - 1] ?? startOffset; + const endOffset = Math.min(documentText.length, lastCharOffset + 1); + let score = 55; + + if (prefix) { + const prefixText = index.normalizedText.slice(Math.max(0, matchIndex - prefix.length), matchIndex); + if (prefixText === prefix) { + score += 20; + } + } + + if (suffix) { + const suffixText = index.normalizedText.slice(matchIndex + normalizedNeedle.length, matchIndex + normalizedNeedle.length + suffix.length); + if (suffixText === suffix) { + score += 20; + } + } + + if (hashString(normalizeSnippet(documentText.slice(startOffset, endOffset))) === anchor.normalizedTextHash) { + score += 10; + } + + score += proximityScore(startOffset, originalStartOffset, Math.max(1, normalizedNeedle.length)); + candidates.push({ startOffset, endOffset, score }); + searchIndex = matchIndex + 1; + } + + return pickBestCandidate(candidates, 65); +} + +function findContextualAnchorMatch( + anchor: AnnotationAnchor, + documentText: string, + index: TextIndex, + originalStartOffset: number, +): CandidateMatch | undefined { + const prefix = normalizeSnippet(anchor.prefixContext); + const suffix = normalizeSnippet(anchor.suffixContext); + const normalizedSelection = normalizeSnippet(anchor.selectedText); + + if (!prefix || !suffix || Math.max(prefix.length, suffix.length) < MIN_CONTEXT_CHARS || !normalizedSelection) { + return undefined; + } + + const maxGap = Math.max(normalizedSelection.length * 3, normalizedSelection.length + 80, 120); + const candidates: CandidateMatch[] = []; + let prefixIndex = 0; + + while (prefixIndex <= index.normalizedText.length) { + const prefixMatchIndex = index.normalizedText.indexOf(prefix, prefixIndex); + if (prefixMatchIndex === -1) { + break; + } + + let suffixIndex = prefixMatchIndex + prefix.length; + while (suffixIndex <= index.normalizedText.length) { + const suffixMatchIndex = index.normalizedText.indexOf(suffix, suffixIndex); + if (suffixMatchIndex === -1) { + break; + } + + const normalizedGap = suffixMatchIndex - (prefixMatchIndex + prefix.length); + if (normalizedGap <= 0) { + suffixIndex = suffixMatchIndex + 1; + continue; + } + + if (normalizedGap > maxGap) { + break; + } + + const prefixEndOffset = (index.normalizedOffsets[prefixMatchIndex + prefix.length - 1] ?? 0) + 1; + const suffixStartOffset = index.normalizedOffsets[suffixMatchIndex] ?? prefixEndOffset; + if (suffixStartOffset <= prefixEndOffset) { + suffixIndex = suffixMatchIndex + 1; + continue; + } + + const trimmedOffsets = trimCandidateWhitespace(anchor.selectedText, documentText, prefixEndOffset, suffixStartOffset); + if (trimmedOffsets.endOffset <= trimmedOffsets.startOffset) { + suffixIndex = suffixMatchIndex + 1; + continue; + } + + const candidateText = documentText.slice(trimmedOffsets.startOffset, trimmedOffsets.endOffset); + const similarity = diceCoefficient(normalizedSelection, normalizeSnippet(candidateText)); + if (similarity < 0.35) { + suffixIndex = suffixMatchIndex + 1; + continue; + } + + let score = 75; + score += Math.round(similarity * 20); + score += proximityScore(trimmedOffsets.startOffset, originalStartOffset, Math.max(1, normalizedSelection.length)); + candidates.push({ startOffset: trimmedOffsets.startOffset, endOffset: trimmedOffsets.endOffset, score }); + suffixIndex = suffixMatchIndex + 1; + } + + prefixIndex = prefixMatchIndex + 1; + } + + return pickBestCandidate(candidates, 85); +} + +function pickBestCandidate(candidates: CandidateMatch[], minimumScore: number): CandidateMatch | undefined { + if (candidates.length === 0) { + return undefined; + } + + const ranked = [...candidates].sort((left, right) => right.score - left.score); + const best = ranked[0]; + const secondBest = ranked[1]; + + if (best.score < minimumScore) { + return undefined; + } + + if (secondBest && best.score - secondBest.score < 15) { + return undefined; + } + + return best; +} + +function proximityScore(candidateStart: number, originalStart: number, anchorLength: number): number { + const distance = Math.abs(candidateStart - originalStart); + const unit = Math.max(16, anchorLength * 2); + return Math.max(0, 12 - Math.floor(distance / unit)); +} + +function diceCoefficient(left: string, right: string): number { + if (!left || !right) { + return 0; + } + + if (left === right) { + return 1; + } + + const leftBigrams = buildBigrams(left); + const rightBigrams = buildBigrams(right); + const remaining = new Map(); + + rightBigrams.forEach(bigram => { + remaining.set(bigram, (remaining.get(bigram) ?? 0) + 1); + }); + + let intersection = 0; + leftBigrams.forEach(bigram => { + const count = remaining.get(bigram) ?? 0; + if (count > 0) { + remaining.set(bigram, count - 1); + intersection += 1; + } + }); + + return (2 * intersection) / (leftBigrams.length + rightBigrams.length); +} + +function buildBigrams(value: string): string[] { + if (value.length < 2) { + return [value]; + } + + const bigrams: string[] = []; + for (let index = 0; index < value.length - 1; index += 1) { + bigrams.push(value.slice(index, index + 2)); + } + return bigrams; +} + +function buildTextIndex(documentText: string): TextIndex { + return { + lineOffsets: buildLineOffsets(documentText), + ...buildNormalizedText(documentText), + }; +} + +function buildLineOffsets(documentText: string): number[] { + const offsets = [0]; + for (let index = 0; index < documentText.length; index += 1) { + if (documentText[index] === '\n') { + offsets.push(index + 1); + } + } + return offsets; +} + +function buildNormalizedText(documentText: string): { normalizedText: string; normalizedOffsets: number[] } { + let normalizedText = ''; + const normalizedOffsets: number[] = []; + let pendingWhitespaceOffset: number | undefined; + + for (let index = 0; index < documentText.length; index += 1) { + const character = documentText[index]; + if (/\s/.test(character)) { + pendingWhitespaceOffset ??= index; + continue; + } + + const previousCharacter = normalizedText.length > 0 ? normalizedText[normalizedText.length - 1] : undefined; + if ( + pendingWhitespaceOffset !== undefined + && previousCharacter + && !isNormalizedPunctuation(previousCharacter) + && !isNormalizedPunctuation(character) + ) { + normalizedText += ' '; + normalizedOffsets.push(pendingWhitespaceOffset); + } + + normalizedText += character; + normalizedOffsets.push(index); + pendingWhitespaceOffset = undefined; + } + + return { normalizedText, normalizedOffsets }; +} + +function clampStoredRange(range: vscode.Range, documentText: string, lineOffsets: number[]): { startOffset: number; endOffset: number } { + return clampRangeOffsets(range, documentText, lineOffsets); +} + +function clampRangeOffsets( + range: vscode.Range | StoredRange, + documentText: string, + lineOffsets: number[], +): { startOffset: number; endOffset: number } { + const startOffset = clampOffset(positionToOffset(range.start.line, range.start.character, documentText, lineOffsets), documentText.length); + const endOffset = clampOffset(positionToOffset(range.end.line, range.end.character, documentText, lineOffsets), documentText.length); + + if (endOffset < startOffset) { + return { startOffset, endOffset: startOffset }; + } + + return { startOffset, endOffset }; +} + +function positionToOffset(line: number, character: number, documentText: string, lineOffsets: number[]): number { + if (line < 0) { + return 0; + } + + if (line >= lineOffsets.length) { + return documentText.length; + } + + const lineStart = lineOffsets[line]; + const nextLineStart = line + 1 < lineOffsets.length ? lineOffsets[line + 1] : documentText.length; + return Math.min(lineStart + Math.max(0, character), nextLineStart); +} + +function clampOffset(offset: number, documentLength: number): number { + return Math.max(0, Math.min(offset, documentLength)); +} + +function createRangeFromOffsets(startOffset: number, endOffset: number, lineOffsets: number[]): vscode.Range { + return new vscode.Range(offsetToPosition(startOffset, lineOffsets), offsetToPosition(endOffset, lineOffsets)); +} + +function offsetToPosition(offset: number, lineOffsets: number[]): vscode.Position { + let low = 0; + let high = lineOffsets.length - 1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const lineStart = lineOffsets[mid]; + const nextLineStart = mid + 1 < lineOffsets.length ? lineOffsets[mid + 1] : Number.MAX_SAFE_INTEGER; + + if (offset < lineStart) { + high = mid - 1; + continue; + } + + if (offset >= nextLineStart) { + low = mid + 1; + continue; + } + + return new vscode.Position(mid, offset - lineStart); + } + + const lastLine = Math.max(0, lineOffsets.length - 1); + return new vscode.Position(lastLine, Math.max(0, offset - lineOffsets[lastLine])); +} + +function normalizeSnippet(value: string): string { + return value + .replace(/\s+/g, ' ') + .replace(/\s*([()[\]{}.,;:+\-*/%<>=!?&|])\s*/g, '$1') + .trim(); +} + +function isNormalizedPunctuation(value: string): boolean { + return /[()[\]{}.,;:+\-*/%<>=!?&|]/.test(value); +} + +function trimCandidateWhitespace( + selectedText: string, + documentText: string, + startOffset: number, + endOffset: number, +): { startOffset: number; endOffset: number } { + let nextStartOffset = startOffset; + let nextEndOffset = endOffset; + + if (!/^\s/.test(selectedText)) { + while (nextStartOffset < nextEndOffset && /\s/.test(documentText[nextStartOffset])) { + nextStartOffset += 1; + } + } + + if (!/\s$/.test(selectedText)) { + while (nextEndOffset > nextStartOffset && /\s/.test(documentText[nextEndOffset - 1])) { + nextEndOffset -= 1; + } + } + + return { startOffset: nextStartOffset, endOffset: nextEndOffset }; +} + +function hashString(value: string): string { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} + +function anchorsEqual(left?: AnnotationAnchor, right?: AnnotationAnchor): boolean { + if (!left && !right) { + return true; + } + + if (!left || !right) { + return false; + } + + return left.selectedText === right.selectedText + && left.prefixContext === right.prefixContext + && left.suffixContext === right.suffixContext + && left.selectedTextHash === right.selectedTextHash + && left.normalizedTextHash === right.normalizedTextHash + && left.contextHash === right.contextHash; +} \ No newline at end of file diff --git a/src/managers/annotationCRUD.ts b/src/managers/annotationCRUD.ts index 9c6a369..f05b8d0 100644 --- a/src/managers/annotationCRUD.ts +++ b/src/managers/annotationCRUD.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { Annotation } from '../types'; +import { captureAnnotationAnchor } from './annotationAnchors'; import { AnnotationDecorations } from './annotationDecorations'; import { AnnotationStorageManager } from './annotationStorage'; @@ -27,6 +28,7 @@ export class AnnotationCRUD { color?: string ): Promise { const filePath = editor.document.uri.fsPath; + const documentText = editor.document.getText(); const selectedText = editor.document.getText(range); const annotation: Annotation = { @@ -39,7 +41,8 @@ export class AnnotationCRUD { timestamp: new Date(), resolved: false, tags: tags || [], - color: color || '#ffc107' + color: color || '#ffc107', + anchor: captureAnnotationAnchor(documentText, range), }; if (!this.annotations.has(filePath)) { @@ -113,10 +116,16 @@ export class AnnotationCRUD { if (color) { annotation.color = color; } + + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor && activeEditor.document.uri.fsPath === filePath) { + annotation.text = activeEditor.document.getText(annotation.range); + annotation.anchor = captureAnnotationAnchor(activeEditor.document.getText(), annotation.range); + } + await this.storage.saveAnnotations(); // Update decorations for the active editor if it matches - const activeEditor = vscode.window.activeTextEditor; if (activeEditor && activeEditor.document.uri.fsPath === filePath) { this.decorations.updateDecorations(activeEditor, fileAnnotations); } @@ -205,20 +214,16 @@ export class AnnotationCRUD { const fileAnnotations = this.annotations.get(filePath); if (fileAnnotations) { const before = fileAnnotations.length; - this.annotations.set( - filePath, - fileAnnotations.filter(ann => !ann.resolved) - ); - deletedCount = before - (fileAnnotations.length); + const remainingAnnotations = fileAnnotations.filter(ann => !ann.resolved); + this.annotations.set(filePath, remainingAnnotations); + deletedCount = before - remainingAnnotations.length; } } else { this.annotations.forEach((fileAnnotations, path) => { const before = fileAnnotations.length; - this.annotations.set( - path, - fileAnnotations.filter(ann => !ann.resolved) - ); - deletedCount += before - (fileAnnotations.length); + const remainingAnnotations = fileAnnotations.filter(ann => !ann.resolved); + this.annotations.set(path, remainingAnnotations); + deletedCount += before - remainingAnnotations.length; }); } diff --git a/src/managers/annotationExportService.ts b/src/managers/annotationExportService.ts new file mode 100644 index 0000000..74296a4 --- /dev/null +++ b/src/managers/annotationExportService.ts @@ -0,0 +1,180 @@ +import * as vscode from 'vscode'; +import { CopilotExporter } from '../copilotExporter'; +import { + Annotation, + AnnotationStatistics, + ExportData, + ExportOptions, +} from '../types'; +import { resolveWorkspaceFolderForAnnotations } from '../utils/workspaceContext'; +import { AnnotationExporter } from './annotationExporter'; + +export type CopilotPreferredFormat = 'conversational' | 'structured' | 'compact'; +export type CopilotIntent = 'review' | 'bugs' | 'optimization' | 'documentation'; + +export interface PreparedExport { + annotations: Annotation[]; + content: string; + language: 'markdown' | 'xml'; +} + +export interface RuntimeExportSettings { + contextLines: number; + includeImports: boolean; + copilotEnabled: boolean; + autoAttachContext: boolean; + preferredFormat: CopilotPreferredFormat; + autoOpenChat: boolean; +} + +export class AnnotationExportService { + private annotationExporter: AnnotationExporter; + + constructor( + private annotations: Map, + resolveTagLabels: (tagIds?: readonly string[]) => string[] = (tagIds) => [...(tagIds || [])] + ) { + this.annotationExporter = new AnnotationExporter(this.annotations, resolveTagLabels); + } + + getRuntimeSettings(): RuntimeExportSettings { + const config = vscode.workspace.getConfiguration('annotative'); + + return { + contextLines: Math.max(0, config.get('export.contextLines', 5)), + includeImports: config.get('export.includeImports', true), + copilotEnabled: config.get('copilot.enabled', true), + autoAttachContext: config.get('copilot.autoAttachContext', true), + preferredFormat: config.get('copilot.preferredFormat', 'conversational'), + autoOpenChat: config.get('copilot.autoOpenChat', false), + }; + } + + isCopilotEnabled(): boolean { + return this.getRuntimeSettings().copilotEnabled; + } + + getAllAnnotations(): Annotation[] { + return this.annotationExporter.getAllAnnotations(); + } + + getAnnotationsForFile(filePath: string): Annotation[] { + return this.annotationExporter.getAnnotationsForFile(filePath); + } + + getAllTags(): string[] { + return this.annotationExporter.getAllTags(); + } + + getStatistics(): AnnotationStatistics { + return this.annotationExporter.getStatistics(); + } + + async exportAnnotations(): Promise { + return this.annotationExporter.exportAnnotations(); + } + + async exportToMarkdown(): Promise { + return this.annotationExporter.exportToMarkdown(); + } + + prepareCopilotExport(annotations: Annotation[]): PreparedExport { + const settings = this.getRuntimeSettings(); + + switch (settings.preferredFormat) { + case 'structured': + return { + annotations, + content: CopilotExporter.exportForCopilotContext(annotations), + language: 'xml', + }; + case 'compact': + return { + annotations, + content: CopilotExporter.exportCompact(annotations), + language: 'markdown', + }; + case 'conversational': + default: + return { + annotations, + content: CopilotExporter.exportForCopilotChat(annotations), + language: 'markdown', + }; + } + } + + prepareCopilotIntentExport(annotations: Annotation[], intent: CopilotIntent): PreparedExport { + const filtered = CopilotExporter.filterByIntent(annotations, intent); + const settings = this.getRuntimeSettings(); + + if (settings.preferredFormat === 'conversational') { + return { + annotations: filtered.annotations, + content: CopilotExporter.exportForCopilotChat(filtered.annotations, filtered.title), + language: 'markdown', + }; + } + + return this.prepareCopilotExport(filtered.annotations); + } + + prepareAIExport( + annotations: Annotation[], + format: ExportOptions['format'], + includeResolved: boolean + ): PreparedExport { + const settings = this.getRuntimeSettings(); + + return { + annotations, + content: CopilotExporter.exportForAI(annotations, { + format, + includeResolved, + contextLines: settings.contextLines, + includeImports: settings.includeImports, + includeFunction: false, + }), + language: format === 'claude' ? 'xml' : 'markdown', + }; + } + + async formatAnnotationForCopilot(annotation: Annotation): Promise { + const settings = this.getRuntimeSettings(); + if (!settings.autoAttachContext) { + return CopilotExporter.formatAsQuickContext(annotation); + } + + return CopilotExporter.formatAnnotationForCopilot(annotation, { + contextLines: settings.contextLines, + includeImports: settings.includeImports, + smartContext: true, + }); + } + + async formatQuickCopilotContext(annotation: Annotation): Promise { + return CopilotExporter.formatAsQuickContext(annotation); + } + + async saveCopilotExport(annotations: Annotation[], sessionName?: string): Promise { + const workspaceFolder = resolveWorkspaceFolderForAnnotations(annotations); + if (!workspaceFolder) { + throw new Error('No workspace folder available for export'); + } + + return CopilotExporter.exportToWorkspace(annotations, workspaceFolder.uri.fsPath, sessionName); + } + + async openCopilotChatIfConfigured(force = false): Promise { + if (!force && !this.getRuntimeSettings().autoOpenChat) { + return false; + } + + try { + await vscode.commands.executeCommand('workbench.panel.chat.view.copilot.focus'); + return true; + } catch { + return false; + } + } +} \ No newline at end of file diff --git a/src/managers/annotationExporter.ts b/src/managers/annotationExporter.ts index a7e9fc9..9cff845 100644 --- a/src/managers/annotationExporter.ts +++ b/src/managers/annotationExporter.ts @@ -1,23 +1,26 @@ import * as vscode from 'vscode'; import { Annotation, ExportData } from '../types'; +import { getRelativePathForFile, getWorkspaceNameForAnnotations, groupAnnotationsByFile } from './exportSupport'; /** * Export and utility functions for annotations */ export class AnnotationExporter { - constructor(private annotations: Map) {} + constructor( + private annotations: Map, + private resolveTagLabels: (tagIds?: readonly string[]) => string[] = (tagIds) => [...(tagIds || [])] + ) {} /** * Export all annotations */ async exportAnnotations(): Promise { - const workspaceFolders = vscode.workspace.workspaceFolders; - const workspaceName = workspaceFolders ? workspaceFolders[0].name : 'Unknown Workspace'; + const annotations = this.getAllAnnotations(); return { - annotations: this.getAllAnnotations(), + annotations, exportedAt: new Date(), - workspaceName + workspaceName: getWorkspaceNameForAnnotations(annotations) }; } @@ -35,16 +38,10 @@ export class AnnotationExporter { } // Group annotations by file - const annotationsByFile = new Map(); - exportData.annotations.forEach(annotation => { - if (!annotationsByFile.has(annotation.filePath)) { - annotationsByFile.set(annotation.filePath, []); - } - annotationsByFile.get(annotation.filePath)!.push(annotation); - }); + const annotationsByFile = groupAnnotationsByFile(exportData.annotations); annotationsByFile.forEach((annotations, filePath) => { - const relativePath = vscode.workspace.asRelativePath(filePath); + const relativePath = getRelativePathForFile(filePath); markdown += `## ${relativePath}\n\n`; annotations.forEach((annotation, index) => { @@ -59,7 +56,7 @@ export class AnnotationExporter { markdown += `**Comment:**\n${annotation.comment}\n\n`; if (annotation.tags && annotation.tags.length > 0) { - markdown += `**Tags:** ${annotation.tags.join(', ')}\n\n`; + markdown += `**Tags:** ${this.resolveTagLabels(annotation.tags).join(', ')}\n\n`; } markdown += '---\n\n'; @@ -95,8 +92,7 @@ export class AnnotationExporter { this.annotations.forEach(fileAnnotations => { fileAnnotations.forEach(annotation => { if (annotation.tags) { - annotation.tags.forEach(tag => { - const tagId = typeof tag === 'string' ? tag : tag.id; + annotation.tags.forEach(tagId => { tagSet.add(tagId); }); } diff --git a/src/managers/annotationManager.ts b/src/managers/annotationManager.ts index 3a3fc58..4cb3a2e 100644 --- a/src/managers/annotationManager.ts +++ b/src/managers/annotationManager.ts @@ -1,14 +1,24 @@ import * as vscode from 'vscode'; -import { Annotation, AnnotationTag, TagCategory, TagMetadata, AnnotationStatistics, TagSuggestion, ExportData } from '../types'; +import { + Annotation, + AnnotationStatistics, + AnnotationTag, + AnnotationTagOption, + ExportData, + TagCategory, + TagMetadata, + TagPriority, + TagSuggestion, +} from '../types'; import { TagManager } from '../tags'; +import { reattachAnnotation } from './annotationAnchors'; import { AnnotationCRUD } from './annotationCRUD'; import { AnnotationDecorations } from './annotationDecorations'; +import { AnnotationExportService } from './annotationExportService'; import { AnnotationStorageManager } from './annotationStorage'; -import { AnnotationExporter } from './annotationExporter'; /** - * Main annotation manager - orchestrates all annotation operations - * Delegates to specialized modules for specific responsibilities + * Main annotation manager - orchestrates all annotation operations. */ export class AnnotationManager { private annotations: Map = new Map(); @@ -16,29 +26,32 @@ export class AnnotationManager { private crud: AnnotationCRUD; private decorations: AnnotationDecorations; private storage: AnnotationStorageManager; - private exporter: AnnotationExporter; + private exportService: AnnotationExportService; private onDidChangeAnnotationsEmitter = new vscode.EventEmitter(); + private persistenceScheduled = false; public readonly onDidChangeAnnotations = this.onDidChangeAnnotationsEmitter.event; + public readonly ready: Promise; constructor(private context: vscode.ExtensionContext) { this.tagManager = new TagManager(); this.decorations = new AnnotationDecorations(); this.storage = new AnnotationStorageManager(this.annotations, context); this.crud = new AnnotationCRUD(this.annotations, this.decorations, this.storage); - this.exporter = new AnnotationExporter(this.annotations); + this.exportService = new AnnotationExportService(this.annotations, (tagIds) => this.resolveTagLabels(tagIds)); - this.initialize(); + this.ready = this.initialize(); } - /** - * Initialize manager - */ private async initialize(): Promise { - await this.storage.loadAnnotations(); - this.loadCustomTags(); - } + await this.loadCustomTags(); + const annotationLoad = await this.storage.loadAnnotations(); + const migratedAnnotations = this.normalizeLoadedAnnotations(); + const rebasedAnnotations = await this.rebaseStoredAnnotations(); - // ============== Tag Management ============== + if (annotationLoad.needsSave || migratedAnnotations || rebasedAnnotations) { + await this.storage.saveAnnotations(); + } + } getTagManager(): TagManager { return this.tagManager; @@ -52,6 +65,41 @@ export class AnnotationManager { return this.tagManager.getCustomTags(); } + getTagOptions(): AnnotationTagOption[] { + return this.tagManager + .getAllTags() + .map(tag => ({ + id: tag.id, + label: tag.name, + color: tag.metadata?.color, + priority: tag.metadata?.priority, + })) + .sort((left, right) => left.label.localeCompare(right.label)); + } + + resolveTagLabel(tagId: string): string { + return this.tagManager.getTag(tagId)?.name || tagId; + } + + resolveTagLabels(tagIds?: readonly string[]): string[] { + if (!tagIds || tagIds.length === 0) { + return []; + } + + return tagIds.map(tagId => this.resolveTagLabel(tagId)); + } + + getAnnotationPriority(annotation: Annotation): TagPriority | undefined { + const priorityOrder: TagPriority[] = ['critical', 'high', 'medium', 'low']; + const priorities = (annotation.tags || []) + .map(tagId => this.tagManager.getTagPriority(tagId)) + .filter((priority): priority is TagPriority => + priority === 'critical' || priority === 'high' || priority === 'medium' || priority === 'low' + ); + + return priorityOrder.find(priority => priorities.includes(priority)); + } + async createCustomTag(name: string, category: TagCategory, metadata?: TagMetadata): Promise { const tag = this.tagManager.createCustomTag(name, category, metadata); await this.saveCustomTags(); @@ -78,8 +126,6 @@ export class AnnotationManager { return this.tagManager.suggestTagsFromComment(comment); } - // ============== CRUD Operations ============== - async addAnnotation( editor: vscode.TextEditor, range: vscode.Range, @@ -147,104 +193,106 @@ export class AnnotationManager { return result; } - // ============== Query Operations ============== - - /** - * Get all tags used in annotations - */ getAllTags(): string[] { - // Get tags used in annotations - const usedTags = this.exporter.getAllTags(); - - // Get all preset and custom tag IDs - const presetTagIds = this.tagManager.getPresetTags().map(t => t.id); - const customTagIds = this.tagManager.getCustomTags().map(t => t.id); - - // Combine and deduplicate + const usedTags = this.exportService.getAllTags(); + const presetTagIds = this.tagManager.getPresetTags().map(tag => tag.id); + const customTagIds = this.tagManager.getCustomTags().map(tag => tag.id); const allTags = new Set([...usedTags, ...presetTagIds, ...customTagIds]); return Array.from(allTags).sort(); } - /** - * Get only tags that are used in current annotations - */ getUsedTags(): string[] { - return this.exporter.getAllTags(); + return this.exportService.getAllTags(); } getAnnotationsForFile(filePath: string): Annotation[] { - return this.exporter.getAnnotationsForFile(filePath); + const openDocument = this.findOpenDocument(filePath); + if (openDocument && this.rebaseAnnotationsForText(filePath, openDocument.getText())) { + this.scheduleAnnotationPersistence(); + } + + return this.exportService.getAnnotationsForFile(filePath); } getAllAnnotations(): Annotation[] { - return this.exporter.getAllAnnotations(); + this.refreshOpenDocuments(); + return this.exportService.getAllAnnotations(); } getStatistics(): AnnotationStatistics { - return this.exporter.getStatistics(); + this.refreshOpenDocuments(); + return this.exportService.getStatistics(); } - // ============== Decoration & Styling ============== - updateDecorations(editor: vscode.TextEditor): void { - const fileAnnotations = this.exporter.getAnnotationsForFile(editor.document.uri.fsPath); + if (this.rebaseAnnotationsForText(editor.document.uri.fsPath, editor.document.getText())) { + this.scheduleAnnotationPersistence(); + } + + const fileAnnotations = this.exportService.getAnnotationsForFile(editor.document.uri.fsPath); this.decorations.updateDecorations(editor, fileAnnotations); } - // ============== Export & Import ============== + async rebaseAnnotationsForDocument(document: vscode.TextDocument): Promise { + if (document.uri.scheme !== 'file') { + return false; + } + + const changed = this.rebaseAnnotationsForText(document.uri.fsPath, document.getText()); + if (changed) { + await this.storage.saveAnnotations(); + this.notifyAnnotationsChanged(); + } + + return changed; + } async exportAnnotations(): Promise { - return this.exporter.exportAnnotations(); + return this.exportService.exportAnnotations(); } async exportToMarkdown(): Promise { - return this.exporter.exportToMarkdown(); + return this.exportService.exportToMarkdown(); } - // ============== Project Storage ============== + getExportService(): AnnotationExportService { + return this.exportService; + } - /** - * Check if project-based storage is active - */ isProjectStorageActive(): boolean { return this.storage.isProjectStorageActive(); } - /** - * Get the current storage directory - */ getStorageDirectory(): string { return this.storage.getStorageDirectory(); } - /** - * Initialize project-based storage (.annotative folder) - */ - async initializeProjectStorage(migrateExisting: boolean = false): Promise { - const result = await this.storage.initializeProjectStorage(migrateExisting); - - // Reload annotations and tags from the new location - await this.storage.loadAnnotations(); + async initializeProjectStorage(): Promise { + const result = await this.storage.initializeProjectStorage(); await this.loadCustomTags(); + const annotationLoad = await this.storage.loadAnnotations(); + const migratedAnnotations = this.normalizeLoadedAnnotations(); + if (annotationLoad.needsSave || migratedAnnotations) { + await this.storage.saveAnnotations(); + } this.notifyAnnotationsChanged(); return result; } - /** - * Refresh storage detection - */ refreshStorageDetection(): void { this.storage.refreshStorageDetection(); } - // ============== Persistence ============== - private async loadCustomTags(): Promise { try { - const customTags = await this.storage.loadCustomTags(); - if (customTags && customTags.length > 0) { - this.tagManager.importCustomTags(customTags); + const loaded = await this.storage.loadCustomTags(); + if (loaded.tags.length > 0) { + this.tagManager.importCustomTags(loaded.tags); + } + + if (loaded.needsSave) { + await this.saveCustomTags(); } } catch (error) { console.error('Failed to load custom tags:', error); @@ -260,17 +308,129 @@ export class AnnotationManager { } } - /** - * Notify listeners that annotations have changed - */ + private normalizeLoadedAnnotations(): boolean { + const allTags = this.tagManager.getAllTags(); + const tagsById = new Map(allTags.map(tag => [tag.id.toLowerCase(), tag.id])); + const tagsByName = new Map(allTags.map(tag => [tag.name.toLowerCase(), tag.id])); + let changed = false; + + this.annotations.forEach(fileAnnotations => { + fileAnnotations.forEach(annotation => { + const nextTags: string[] = []; + + (annotation.tags || []).forEach(rawTagId => { + const normalizedInput = rawTagId.trim(); + if (!normalizedInput) { + changed = true; + return; + } + + const lookupKey = normalizedInput.toLowerCase(); + const resolvedId = tagsById.get(lookupKey) || tagsByName.get(lookupKey) || normalizedInput; + if (resolvedId !== rawTagId) { + changed = true; + } + + if (!nextTags.includes(resolvedId)) { + nextTags.push(resolvedId); + } else { + changed = true; + } + }); + + if ((annotation.tags || []).length !== nextTags.length) { + changed = true; + } + + annotation.tags = nextTags; + + if (annotation.anchor && annotation.anchor.selectedText !== annotation.text) { + changed = true; + } + }); + }); + + return changed; + } + + private async rebaseStoredAnnotations(): Promise { + const filePaths = Array.from(this.annotations.keys()); + let changed = false; + + for (const filePath of filePaths) { + try { + const fileContents = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)); + const documentText = Buffer.from(fileContents).toString('utf-8'); + changed = this.rebaseAnnotationsForText(filePath, documentText) || changed; + } catch { + continue; + } + } + + return changed; + } + + private rebaseAnnotationsForText(filePath: string, documentText: string): boolean { + const fileAnnotations = this.annotations.get(filePath); + if (!fileAnnotations || fileAnnotations.length === 0) { + return false; + } + + let changed = false; + fileAnnotations.forEach(annotation => { + const reattached = reattachAnnotation(annotation, documentText); + if (!reattached.changed) { + return; + } + + annotation.range = reattached.range; + annotation.text = reattached.text; + annotation.anchor = reattached.anchor; + changed = true; + }); + + return changed; + } + + private refreshOpenDocuments(): void { + let changed = false; + + vscode.workspace.textDocuments.forEach(document => { + if (document.uri.scheme !== 'file') { + return; + } + + changed = this.rebaseAnnotationsForText(document.uri.fsPath, document.getText()) || changed; + }); + + if (changed) { + this.scheduleAnnotationPersistence(); + } + } + + private findOpenDocument(filePath: string): vscode.TextDocument | undefined { + return vscode.workspace.textDocuments.find(document => document.uri.scheme === 'file' && document.uri.fsPath === filePath); + } + + private scheduleAnnotationPersistence(): void { + if (this.persistenceScheduled) { + return; + } + + this.persistenceScheduled = true; + void Promise.resolve().then(async () => { + this.persistenceScheduled = false; + await this.storage.saveAnnotations(); + this.notifyAnnotationsChanged(); + }); + } + private notifyAnnotationsChanged(): void { this.onDidChangeAnnotationsEmitter.fire(); } - /** - * Dispose resources - */ dispose(): void { + void this.context; this.decorations.dispose(); this.onDidChangeAnnotationsEmitter.dispose(); } diff --git a/src/managers/annotationStorage.ts b/src/managers/annotationStorage.ts index bb7cb50..dffbdc0 100644 --- a/src/managers/annotationStorage.ts +++ b/src/managers/annotationStorage.ts @@ -1,110 +1,120 @@ -import * as path from 'path'; import * as fs from 'fs'; +import * as path from 'path'; import * as vscode from 'vscode'; -import { Annotation, AnnotationStorage as IAnnotationStorage, AnnotationTag } from '../types'; +import { + Annotation, + AnnotationStorageFile, + AnnotationTag, + AnnotationAnchor, + StoredAnnotation, + TagPriority, + TagStorageFile, +} from '../types'; +import { + findWorkspaceFolderContainingChild, + getPreferredWorkspaceFolder, + resolveWorkspaceFolderForAnnotations, +} from '../utils/workspaceContext'; + +const STORAGE_SCHEMA_VERSION = 2; + +export interface LoadAnnotationsResult { + needsSave: boolean; +} + +export interface LoadCustomTagsResult { + tags: AnnotationTag[]; + needsSave: boolean; +} + +interface ParsedAnnotationsPayload { + workspaceAnnotations: Record; + needsSave: boolean; +} + +interface ParsedCustomTagsPayload { + customTags: AnnotationTag[]; + needsSave: boolean; +} /** - * Handles persistence of annotations and custom tags - * Uses project-scoped storage (.annotative folder) exclusively - * Each workspace has isolated annotation data + * Handles persistence of annotations and custom tags. + * Uses project-scoped storage (.annotative folder) exclusively. */ export class AnnotationStorageManager { - private storageFilePath: string; - private customTagsPath: string; + private storageFilePath = ''; + private customTagsPath = ''; private projectStorageDir: string | undefined; + private writeQueue: Promise = Promise.resolve(); constructor( private annotations: Map, context: vscode.ExtensionContext ) { - // Initialize paths - will be set properly when project storage is created - this.storageFilePath = ''; - this.customTagsPath = ''; - - // Check for existing project storage + void context; this.detectProjectStorage(); } - /** - * Detect if project-based storage exists (.annotative folder in workspace root) - */ private detectProjectStorage(): void { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (workspaceFolders && workspaceFolders.length > 0) { - const workspaceRoot = workspaceFolders[0].uri.fsPath; - const annotativeDir = path.join(workspaceRoot, '.annotative'); - - if (fs.existsSync(annotativeDir)) { - this.projectStorageDir = annotativeDir; - this.storageFilePath = path.join(annotativeDir, 'annotations.json'); - this.customTagsPath = path.join(annotativeDir, 'customTags.json'); - } + const storageFolder = findWorkspaceFolderContainingChild('.annotative'); + if (!storageFolder) { + return; + } + + const annotativeDir = path.join(storageFolder.uri.fsPath, '.annotative'); + if (!fs.existsSync(annotativeDir)) { + return; } + + this.projectStorageDir = annotativeDir; + this.storageFilePath = path.join(annotativeDir, 'annotations.json'); + this.customTagsPath = path.join(annotativeDir, 'customTags.json'); } - /** - * Check if project storage is active - */ isProjectStorageActive(): boolean { return !!this.projectStorageDir; } - /** - * Get the current storage directory path - */ getStorageDirectory(): string { return this.projectStorageDir || ''; } - /** - * Ensure project storage exists - creates .annotative folder if needed - * Called automatically when first annotation is added - */ async ensureProjectStorage(): Promise { if (this.projectStorageDir) { - return; // Already initialized + return; } - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - throw new Error('No workspace folder open'); // Cannot create project storage + const workspaceFolder = this.resolveStorageWorkspaceFolder(); + if (!workspaceFolder) { + throw new Error('No workspace folder open'); } - const workspaceRoot = workspaceFolders[0].uri.fsPath; - const annotativeDir = path.join(workspaceRoot, '.annotative'); - - // Create the directory + const annotativeDir = path.join(workspaceFolder.uri.fsPath, '.annotative'); if (!fs.existsSync(annotativeDir)) { - fs.mkdirSync(annotativeDir, { recursive: true }); + await fs.promises.mkdir(annotativeDir, { recursive: true }); - // Create a README to explain the folder purpose const readmePath = path.join(annotativeDir, 'README.md'); const readmeContent = `# Annotative Storage\n\nThis folder contains your project's annotations and custom tags.\n\n## Version Control\n\n**Recommended:** Include this folder in version control to share annotations with your team.\n\n\`\`\`bash\ngit add .annotative/\ngit commit -m "Add annotations"\n\`\`\`\n\n**Private annotations:** Add \`.annotative/\` to your project's \`.gitignore\` file.\n\n## Files\n\n- \`annotations.json\` - All annotations in this project\n- \`customTags.json\` - User-defined tag definitions\n`; - fs.writeFileSync(readmePath, readmeContent, 'utf-8'); + await fs.promises.writeFile(readmePath, readmeContent, 'utf-8'); } - // Set paths this.projectStorageDir = annotativeDir; this.storageFilePath = path.join(annotativeDir, 'annotations.json'); this.customTagsPath = path.join(annotativeDir, 'customTags.json'); } - /** - * Initialize project-based storage (.annotative folder) - * Returns true if successfully created, false if already exists - * @deprecated Use ensureProjectStorage() instead - storage is now auto-created - */ - async initializeProjectStorage(migrateExisting: boolean = false): Promise { - // Capture whether the storage file existed before initialization - const existedBefore = this.storageFilePath && fs.existsSync(this.storageFilePath); + async initializeProjectStorage(): Promise { + const workspaceFolder = this.resolveStorageWorkspaceFolder(); + if (!workspaceFolder) { + throw new Error('No workspace folder open'); + } + + const annotativeDir = path.join(workspaceFolder.uri.fsPath, '.annotative'); + const existedBefore = fs.existsSync(annotativeDir); await this.ensureProjectStorage(); - // Return true only if storage did not exist before but exists after ensuring - return !existedBefore && fs.existsSync(this.storageFilePath); + return !existedBefore && !!this.projectStorageDir; } - /** - * Refresh storage detection (useful after folder changes) - */ refreshStorageDetection(): void { this.projectStorageDir = undefined; this.storageFilePath = ''; @@ -112,80 +122,317 @@ export class AnnotationStorageManager { this.detectProjectStorage(); } - /** - * Load annotations from storage - * Only loads if project storage exists - */ - async loadAnnotations(): Promise { + private resolveStorageWorkspaceFolder(): vscode.WorkspaceFolder | undefined { + const annotationScope = [...this.annotations.entries()].flatMap(([filePath, fileAnnotations]) => + fileAnnotations.length > 0 + ? fileAnnotations + : [{ filePath } as Annotation] + ); + + return resolveWorkspaceFolderForAnnotations(annotationScope) || getPreferredWorkspaceFolder(); + } + + async loadAnnotations(): Promise { try { if (!this.storageFilePath || !fs.existsSync(this.storageFilePath)) { - return; + return { needsSave: false }; } - const data = fs.readFileSync(this.storageFilePath, 'utf-8'); - const storage: { workspaceAnnotations: { [key: string]: Annotation[] } } = JSON.parse(data); + const data = await fs.promises.readFile(this.storageFilePath, 'utf-8'); + const parsed = this.parseAnnotationStorage(JSON.parse(data)); this.annotations.clear(); - Object.entries(storage.workspaceAnnotations).forEach(([filePath, annotations]) => { - const normalizedAnnotations = annotations.map(ann => ({ - ...ann, - timestamp: new Date(ann.timestamp as any) - })); - this.annotations.set(filePath, normalizedAnnotations); + Object.entries(parsed.workspaceAnnotations).forEach(([filePath, annotations]) => { + this.annotations.set( + filePath, + annotations.map(annotation => this.deserializeAnnotation(filePath, annotation)) + ); }); + + return { needsSave: parsed.needsSave }; } catch (error) { console.error('Failed to load annotations:', error); + await this.recoverCorruptFile(this.storageFilePath, 'annotations'); + this.annotations.clear(); + return { needsSave: false }; } } - /** - * Save annotations to storage - * Auto-creates project storage if it doesn't exist - */ async saveAnnotations(): Promise { try { - // Ensure project storage exists - await this.ensureProjectStorage(); + await this.enqueueWrite(async () => { + await this.ensureProjectStorage(); - const storage: IAnnotationStorage = { - workspaceAnnotations: Object.fromEntries(this.annotations) - }; + const storage: AnnotationStorageFile = { + schemaVersion: STORAGE_SCHEMA_VERSION, + workspaceAnnotations: Object.fromEntries( + Array.from(this.annotations.entries()).map(([filePath, annotations]) => [ + filePath, + annotations.map(annotation => this.serializeAnnotation(annotation)) + ]) + ) + }; - fs.writeFileSync(this.storageFilePath, JSON.stringify(storage, null, 2)); + await this.writeJsonAtomically(this.storageFilePath, storage); + }); } catch (error) { console.error('Failed to save annotations:', error); vscode.window.showErrorMessage('Failed to save annotations'); } } - /** - * Load custom tags from storage - */ - async loadCustomTags(): Promise { + async loadCustomTags(): Promise { try { if (this.customTagsPath && fs.existsSync(this.customTagsPath)) { - const data = fs.readFileSync(this.customTagsPath, 'utf-8'); - return JSON.parse(data); + const data = await fs.promises.readFile(this.customTagsPath, 'utf-8'); + const parsed = this.parseCustomTagsStorage(JSON.parse(data)); + return { + tags: parsed.customTags, + needsSave: parsed.needsSave, + }; } } catch (error) { console.error('Failed to load custom tags:', error); + await this.recoverCorruptFile(this.customTagsPath, 'custom tags'); } - return []; + + return { tags: [], needsSave: false }; } - /** - * Save custom tags to storage - * Auto-creates project storage if it doesn't exist - */ async saveCustomTags(tags: AnnotationTag[]): Promise { try { - // Ensure project storage exists - await this.ensureProjectStorage(); + await this.enqueueWrite(async () => { + await this.ensureProjectStorage(); + + const storage: TagStorageFile = { + schemaVersion: STORAGE_SCHEMA_VERSION, + customTags: tags, + }; - fs.writeFileSync(this.customTagsPath, JSON.stringify(tags, null, 2)); + await this.writeJsonAtomically(this.customTagsPath, storage); + }); } catch (error) { console.error('Failed to save custom tags:', error); vscode.window.showErrorMessage('Failed to save custom tags'); } } + + private serializeAnnotation(annotation: Annotation): StoredAnnotation { + return { + ...annotation, + range: { + start: { + line: annotation.range.start.line, + character: annotation.range.start.character, + }, + end: { + line: annotation.range.end.line, + character: annotation.range.end.character, + }, + }, + timestamp: annotation.timestamp.toISOString(), + tags: annotation.tags ? [...annotation.tags] : undefined, + anchor: this.serializeAnchor(annotation.anchor), + }; + } + + private deserializeAnnotation(filePath: string, annotation: StoredAnnotation): Annotation { + const start = annotation.range?.start ?? { line: 0, character: 0 }; + const end = annotation.range?.end ?? start; + const normalizedTags = Array.isArray(annotation.tags) + ? annotation.tags + .map(tag => typeof tag === 'string' ? tag : tag.id || tag.name) + .filter((tagId): tagId is string => typeof tagId === 'string' && tagId.trim().length > 0) + : []; + + return { + ...annotation, + filePath, + range: new vscode.Range( + new vscode.Position(start.line, start.character), + new vscode.Position(end.line, end.character) + ), + timestamp: this.parseTimestamp(annotation.timestamp), + tags: normalizedTags, + priority: this.normalizePriority(annotation.priority), + anchor: this.deserializeAnchor(annotation.anchor), + }; + } + + private serializeAnchor(anchor: AnnotationAnchor | undefined): AnnotationAnchor | undefined { + if (!anchor) { + return undefined; + } + + return { + selectedText: anchor.selectedText, + prefixContext: anchor.prefixContext, + suffixContext: anchor.suffixContext, + selectedTextHash: anchor.selectedTextHash, + normalizedTextHash: anchor.normalizedTextHash, + contextHash: anchor.contextHash, + }; + } + + private deserializeAnchor(rawAnchor: unknown): AnnotationAnchor | undefined { + if (!rawAnchor || typeof rawAnchor !== 'object') { + return undefined; + } + + const candidate = rawAnchor as Partial; + if ( + typeof candidate.selectedText !== 'string' + || typeof candidate.prefixContext !== 'string' + || typeof candidate.suffixContext !== 'string' + || typeof candidate.selectedTextHash !== 'string' + || typeof candidate.normalizedTextHash !== 'string' + || typeof candidate.contextHash !== 'string' + ) { + return undefined; + } + + return { + selectedText: candidate.selectedText, + prefixContext: candidate.prefixContext, + suffixContext: candidate.suffixContext, + selectedTextHash: candidate.selectedTextHash, + normalizedTextHash: candidate.normalizedTextHash, + contextHash: candidate.contextHash, + }; + } + + private parseAnnotationStorage(raw: unknown): ParsedAnnotationsPayload { + if (this.isAnnotationStorageFile(raw)) { + return { + workspaceAnnotations: raw.workspaceAnnotations, + needsSave: raw.schemaVersion !== STORAGE_SCHEMA_VERSION, + }; + } + + if (this.isLegacyAnnotationStorage(raw)) { + return { + workspaceAnnotations: raw.workspaceAnnotations, + needsSave: true, + }; + } + + throw new Error('Invalid annotation storage schema'); + } + + private parseCustomTagsStorage(raw: unknown): ParsedCustomTagsPayload { + if (this.isTagStorageFile(raw)) { + return { + customTags: raw.customTags, + needsSave: raw.schemaVersion !== STORAGE_SCHEMA_VERSION, + }; + } + + if (Array.isArray(raw)) { + return { + customTags: raw, + needsSave: true, + }; + } + + throw new Error('Invalid custom tag storage schema'); + } + + private isAnnotationStorageFile(raw: unknown): raw is AnnotationStorageFile { + if (!raw || typeof raw !== 'object') { + return false; + } + + const candidate = raw as Partial; + return typeof candidate.schemaVersion === 'number' + && !!candidate.workspaceAnnotations + && typeof candidate.workspaceAnnotations === 'object'; + } + + private isLegacyAnnotationStorage(raw: unknown): raw is { workspaceAnnotations: Record } { + if (!raw || typeof raw !== 'object') { + return false; + } + + const candidate = raw as { workspaceAnnotations?: unknown }; + return !!candidate.workspaceAnnotations && typeof candidate.workspaceAnnotations === 'object'; + } + + private isTagStorageFile(raw: unknown): raw is TagStorageFile { + if (!raw || typeof raw !== 'object') { + return false; + } + + const candidate = raw as Partial; + return typeof candidate.schemaVersion === 'number' && Array.isArray(candidate.customTags); + } + + private parseTimestamp(rawTimestamp: string): Date { + const timestamp = new Date(rawTimestamp); + return Number.isNaN(timestamp.getTime()) ? new Date(0) : timestamp; + } + + private normalizePriority(priority: unknown): TagPriority | undefined { + if (priority === 'low' || priority === 'medium' || priority === 'high' || priority === 'critical') { + return priority; + } + + return undefined; + } + + private async writeJsonAtomically(filePath: string, payload: unknown): Promise { + const directory = path.dirname(filePath); + const fileName = path.basename(filePath); + const tempPath = path.join(directory, `${fileName}.tmp`); + const backupPath = path.join(directory, `${fileName}.bak`); + const contents = `${JSON.stringify(payload, null, 2)}\n`; + + await fs.promises.writeFile(tempPath, contents, 'utf-8'); + + const hasExistingFile = fs.existsSync(filePath); + if (hasExistingFile) { + if (fs.existsSync(backupPath)) { + await fs.promises.unlink(backupPath); + } + + await fs.promises.rename(filePath, backupPath); + } + + try { + await fs.promises.rename(tempPath, filePath); + if (fs.existsSync(backupPath)) { + await fs.promises.unlink(backupPath); + } + } catch (error) { + if (fs.existsSync(tempPath)) { + await fs.promises.unlink(tempPath); + } + + if (fs.existsSync(backupPath) && !fs.existsSync(filePath)) { + await fs.promises.rename(backupPath, filePath); + } + + throw error; + } + } + + private async enqueueWrite(operation: () => Promise): Promise { + const nextWrite = this.writeQueue.then(operation, operation); + this.writeQueue = nextWrite.then(() => undefined, () => undefined); + return nextWrite; + } + + private async recoverCorruptFile(filePath: string, label: string): Promise { + if (!filePath || !fs.existsSync(filePath)) { + return; + } + + const parsedPath = path.parse(filePath); + const timestamp = new Date().toISOString().replace(/[.:]/g, '-'); + const recoveredPath = path.join(parsedPath.dir, `${parsedPath.name}.corrupt-${timestamp}${parsedPath.ext}`); + await fs.promises.rename(filePath, recoveredPath); + + void vscode.window.showWarningMessage( + `Annotative recovered an unreadable ${label} file and moved it to ${path.basename(recoveredPath)}.` + ); + } } diff --git a/src/managers/exportSupport.ts b/src/managers/exportSupport.ts new file mode 100644 index 0000000..60f380a --- /dev/null +++ b/src/managers/exportSupport.ts @@ -0,0 +1,22 @@ +import { Annotation } from '../types'; +import { getRelativePathForFile, getWorkspaceNameForAnnotations } from '../utils/workspaceContext'; + +export function groupAnnotationsByFile(annotations: readonly Annotation[]): Map { + const grouped = new Map(); + + annotations.forEach(annotation => { + if (!grouped.has(annotation.filePath)) { + grouped.set(annotation.filePath, []); + } + + grouped.get(annotation.filePath)!.push(annotation); + }); + + grouped.forEach(fileAnnotations => { + fileAnnotations.sort((left, right) => left.range.start.line - right.range.start.line); + }); + + return grouped; +} + +export { getRelativePathForFile, getWorkspaceNameForAnnotations }; \ No newline at end of file diff --git a/src/managers/index.ts b/src/managers/index.ts index 9d7d574..4ded84f 100644 --- a/src/managers/index.ts +++ b/src/managers/index.ts @@ -6,5 +6,6 @@ export { AnnotationManager } from './annotationManager'; export { AnnotationCRUD } from './annotationCRUD'; export { AnnotationDecorations } from './annotationDecorations'; +export { AnnotationExportService } from './annotationExportService'; export { AnnotationStorageManager } from './annotationStorage'; export { AnnotationExporter } from './annotationExporter'; diff --git a/src/test/fixtures/workspace/.vscode/settings.json b/src/test/fixtures/workspace/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/src/test/fixtures/workspace/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/src/test/fixtures/workspace/sample.ts b/src/test/fixtures/workspace/sample.ts new file mode 100644 index 0000000..c20da18 --- /dev/null +++ b/src/test/fixtures/workspace/sample.ts @@ -0,0 +1 @@ +export const sampleValue = 42; \ No newline at end of file diff --git a/src/test/suite/anchoring.test.ts b/src/test/suite/anchoring.test.ts new file mode 100644 index 0000000..2edf840 --- /dev/null +++ b/src/test/suite/anchoring.test.ts @@ -0,0 +1,224 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { AnnotationManager } from '../../managers'; +import { captureAnnotationAnchor } from '../../managers/annotationAnchors'; +import { AnnotationStorageFile } from '../../types'; +import { + clearTestWorkspace, + createAnnotation, + createTestContext, + ensureWorkspaceFile, + getStoragePaths, + readJson, + toStoredAnnotation, + writeJson, +} from './testUtils'; + +suite('Annotation anchoring', () => { + teardown(async () => { + await clearTestWorkspace(); + }); + + test('migrates legacy annotations by capturing anchors without changing unchanged ranges', async () => { + await clearTestWorkspace(); + + const contents = 'const answer = 42;\n'; + const filePath = await ensureWorkspaceFile('anchors-legacy.ts', contents); + const storedAnnotation = buildStoredAnnotation({ + filePath, + originalContents: contents, + selectedText: 'answer', + includeAnchor: false, + }); + + await writeAnnotationsFile(filePath, [storedAnnotation], 1); + + const manager = new AnnotationManager(createTestContext()); + await manager.ready; + + const loadedAnnotation = manager.getAnnotationsForFile(filePath)[0]; + assert.ok(loadedAnnotation.anchor, 'Expected the legacy annotation to gain an anchor during migration.'); + assert.strictEqual(loadedAnnotation.range.start.line, 0); + assert.strictEqual(loadedAnnotation.range.start.character, 6); + assert.strictEqual(loadedAnnotation.text, 'answer'); + + const persisted = await readJson(getStoragePaths().annotationsPath); + assert.strictEqual(persisted.schemaVersion, 2); + assert.ok(persisted.workspaceAnnotations[filePath][0].anchor); + + manager.dispose(); + }); + + test('rebases annotations when lines are inserted above the anchored range', async () => { + await clearTestWorkspace(); + + const originalContents = 'const alpha = 1;\nconst target = alpha + 1;\n'; + const currentContents = 'const inserted = true;\nconst alpha = 1;\nconst target = alpha + 1;\n'; + const filePath = await ensureWorkspaceFile('anchors-shift.ts', currentContents); + const storedAnnotation = buildStoredAnnotation({ + filePath, + originalContents, + selectedText: 'target = alpha + 1', + }); + + await writeAnnotationsFile(filePath, [storedAnnotation], 2); + + const manager = new AnnotationManager(createTestContext()); + await manager.ready; + + const loadedAnnotation = manager.getAnnotationsForFile(filePath)[0]; + assert.strictEqual(loadedAnnotation.range.start.line, 2); + assert.strictEqual(loadedAnnotation.text, 'target = alpha + 1'); + + manager.dispose(); + }); + + test('reattaches annotations across nearby formatting changes and line movement', async () => { + await clearTestWorkspace(); + + const originalContents = 'const total = sum(a, b);\n'; + const currentContents = 'const before = 1;\nconst total = sum(\n a,\n b\n);\n'; + const filePath = await ensureWorkspaceFile('anchors-format.ts', currentContents); + const storedAnnotation = buildStoredAnnotation({ + filePath, + originalContents, + selectedText: 'sum(a, b)', + }); + + await writeAnnotationsFile(filePath, [storedAnnotation], 2); + + const manager = new AnnotationManager(createTestContext()); + await manager.ready; + + const loadedAnnotation = manager.getAnnotationsForFile(filePath)[0]; + assert.strictEqual(loadedAnnotation.range.start.line, 1); + assert.strictEqual(loadedAnnotation.range.end.line, 4); + assert.strictEqual(loadedAnnotation.text, 'sum(\n a,\n b\n)'); + + manager.dispose(); + }); + + test('reattaches annotations when the selected text changes but surrounding context stays stable', async () => { + await clearTestWorkspace(); + + const originalContents = 'function build(user) {\n return getValue(user.id);\n}\n'; + const currentContents = 'function build(user) {\n return buildValue(user.id, ctx);\n}\n'; + const filePath = await ensureWorkspaceFile('anchors-partial.ts', currentContents); + const storedAnnotation = buildStoredAnnotation({ + filePath, + originalContents, + selectedText: 'getValue(user.id)', + }); + + await writeAnnotationsFile(filePath, [storedAnnotation], 2); + + const manager = new AnnotationManager(createTestContext()); + await manager.ready; + + const loadedAnnotation = manager.getAnnotationsForFile(filePath)[0]; + assert.strictEqual(loadedAnnotation.range.start.line, 1); + assert.strictEqual(loadedAnnotation.text, 'buildValue(user.id, ctx)'); + assert.strictEqual(loadedAnnotation.anchor?.selectedText, 'buildValue(user.id, ctx)'); + + manager.dispose(); + }); + + test('falls back conservatively when multiple matches are equally plausible', async () => { + await clearTestWorkspace(); + + const originalContents = 'duplicate(),\nduplicate(),\n'; + const currentContents = 'duplicate(),\nduplicate(),\nduplicate(),\n'; + const filePath = await ensureWorkspaceFile('anchors-ambiguous.ts', currentContents); + const storedAnnotation = buildStoredAnnotation({ + filePath, + originalContents, + selectedText: 'duplicate()', + }); + + await writeAnnotationsFile(filePath, [storedAnnotation], 2); + + const manager = new AnnotationManager(createTestContext()); + await manager.ready; + + const loadedAnnotation = manager.getAnnotationsForFile(filePath)[0]; + assert.strictEqual(loadedAnnotation.range.start.line, 0); + assert.strictEqual(loadedAnnotation.range.start.character, 0); + assert.strictEqual(loadedAnnotation.text, 'duplicate()'); + + manager.dispose(); + }); +}); + +function buildStoredAnnotation(options: { + filePath: string; + originalContents: string; + selectedText: string; + includeAnchor?: boolean; + occurrence?: number; +}) { + const range = findRange(options.originalContents, options.selectedText, options.occurrence ?? 0); + const annotation = createAnnotation({ + filePath: options.filePath, + id: `${pathSafeId(options.filePath)}-${options.selectedText.length}`, + range, + text: options.originalContents.slice( + offsetAt(options.originalContents, range.start), + offsetAt(options.originalContents, range.end) + ), + }); + + if (options.includeAnchor !== false) { + annotation.anchor = captureAnnotationAnchor(options.originalContents, range); + } + + return toStoredAnnotation(annotation); +} + +async function writeAnnotationsFile(filePath: string, storedAnnotations: ReturnType[], schemaVersion: number) { + await writeJson(getStoragePaths().annotationsPath, { + schemaVersion, + workspaceAnnotations: { + [filePath]: storedAnnotations, + }, + } satisfies AnnotationStorageFile); +} + +function findRange(contents: string, selectedText: string, occurrence: number): vscode.Range { + let searchFrom = 0; + let offset = -1; + + for (let index = 0; index <= occurrence; index += 1) { + offset = contents.indexOf(selectedText, searchFrom); + if (offset === -1) { + throw new Error(`Could not find '${selectedText}' in test contents.`); + } + searchFrom = offset + 1; + } + + return new vscode.Range( + positionAt(contents, offset), + positionAt(contents, offset + selectedText.length) + ); +} + +function positionAt(contents: string, offset: number): vscode.Position { + const clampedOffset = Math.max(0, Math.min(offset, contents.length)); + const lines = contents.slice(0, clampedOffset).split('\n'); + return new vscode.Position(lines.length - 1, lines[lines.length - 1].length); +} + +function offsetAt(contents: string, position: vscode.Position): number { + const lines = contents.split('\n'); + let offset = 0; + + for (let line = 0; line < position.line; line += 1) { + offset += lines[line]?.length ?? 0; + offset += 1; + } + + return offset + position.character; +} + +function pathSafeId(filePath: string): string { + return filePath.replace(/[^a-z0-9]+/gi, '-').toLowerCase(); +} \ No newline at end of file diff --git a/src/test/suite/crud.test.ts b/src/test/suite/crud.test.ts new file mode 100644 index 0000000..5a12cc4 --- /dev/null +++ b/src/test/suite/crud.test.ts @@ -0,0 +1,107 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { AnnotationCRUD } from '../../managers'; +import { Annotation } from '../../types'; +import { createAnnotation } from './testUtils'; + +suite('AnnotationCRUD', () => { + test('supports add, edit, resolve, unresolve, and remove flows', async () => { + const annotations = new Map(); + const decorationUpdates: Annotation[][] = []; + let saveCount = 0; + const filePath = 'c:\\workspace\\crud-flow.ts'; + const editor = { + document: { + uri: vscode.Uri.file(filePath), + getText: () => 'const answer = 42;', + }, + } as unknown as vscode.TextEditor; + const crud = new AnnotationCRUD( + annotations, + { + updateDecorations: (_editor: vscode.TextEditor, fileAnnotations: Annotation[]) => { + decorationUpdates.push([...fileAnnotations]); + }, + } as unknown as never, + { + saveAnnotations: async () => { + saveCount += 1; + }, + } as unknown as never + ); + const range = new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 12)); + + const created = await crud.addAnnotation(editor, range, 'Add regression coverage.', ['bug'], '#42A5F5'); + + assert.strictEqual(annotations.get(filePath)?.length, 1); + assert.strictEqual(created.comment, 'Add regression coverage.'); + assert.deepStrictEqual(created.tags, ['bug']); + assert.strictEqual(created.color, '#42A5F5'); + assert.ok(created.author.length > 0); + assert.strictEqual(saveCount, 1); + assert.strictEqual(decorationUpdates.length, 1); + + await crud.editAnnotation(created.id, filePath, 'Updated comment.', ['security'], '#FF5252'); + assert.strictEqual(annotations.get(filePath)?.[0].comment, 'Updated comment.'); + assert.deepStrictEqual(annotations.get(filePath)?.[0].tags, ['security']); + assert.strictEqual(annotations.get(filePath)?.[0].color, '#FF5252'); + + await crud.toggleResolvedStatus(created.id, filePath); + assert.strictEqual(annotations.get(filePath)?.[0].resolved, true); + + await crud.toggleResolvedStatus(created.id, filePath); + assert.strictEqual(annotations.get(filePath)?.[0].resolved, false); + + await crud.removeAnnotation(created.id, filePath); + assert.strictEqual(annotations.get(filePath)?.length, 0); + assert.strictEqual(saveCount, 5); + }); + + test('resolves and deletes resolved annotations at file and workspace scope', async () => { + const fileOne = 'c:\\workspace\\file-one.ts'; + const fileTwo = 'c:\\workspace\\file-two.ts'; + const annotations = new Map([ + [ + fileOne, + [ + createAnnotation({ filePath: fileOne, id: 'one-open', resolved: false }), + createAnnotation({ filePath: fileOne, id: 'one-resolved', resolved: true }), + ], + ], + [ + fileTwo, + [ + createAnnotation({ filePath: fileTwo, id: 'two-open', resolved: false }), + ], + ], + ]); + let saveCount = 0; + const crud = new AnnotationCRUD( + annotations, + { updateDecorations: () => undefined } as unknown as never, + { + saveAnnotations: async () => { + saveCount += 1; + }, + } as unknown as never + ); + + const deletedFromFile = await crud.deleteResolved(fileOne); + assert.strictEqual(deletedFromFile, 1); + assert.deepStrictEqual( + annotations.get(fileOne)?.map(annotation => annotation.id), + ['one-open'] + ); + + const resolvedAcrossWorkspace = await crud.resolveAll(); + assert.strictEqual(resolvedAcrossWorkspace, 2); + assert.ok(annotations.get(fileOne)?.every(annotation => annotation.resolved)); + assert.ok(annotations.get(fileTwo)?.every(annotation => annotation.resolved)); + + const deletedAcrossWorkspace = await crud.deleteResolved(); + assert.strictEqual(deletedAcrossWorkspace, 2); + assert.deepStrictEqual(annotations.get(fileOne), []); + assert.deepStrictEqual(annotations.get(fileTwo), []); + assert.strictEqual(saveCount, 3); + }); +}); \ No newline at end of file diff --git a/src/test/suite/exporter.test.ts b/src/test/suite/exporter.test.ts new file mode 100644 index 0000000..f694c32 --- /dev/null +++ b/src/test/suite/exporter.test.ts @@ -0,0 +1,162 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { AnnotationExporter } from '../../managers'; +import { AnnotationExportService } from '../../managers/annotationExportService'; +import { CopilotExporter } from '../../copilotExporter'; +import { Annotation } from '../../types'; +import { createAnnotation, ensureWorkspaceFile } from './testUtils'; + +suite('Exporters', () => { + const configuration = vscode.workspace.getConfiguration('annotative'); + + teardown(async () => { + await configuration.update('export.contextLines', undefined, vscode.ConfigurationTarget.Workspace); + await configuration.update('export.includeImports', undefined, vscode.ConfigurationTarget.Workspace); + await configuration.update('copilot.autoAttachContext', undefined, vscode.ConfigurationTarget.Workspace); + await configuration.update('copilot.preferredFormat', undefined, vscode.ConfigurationTarget.Workspace); + }); + + test('exports markdown grouped by file with resolved tag labels', async () => { + const filePath = await ensureWorkspaceFile('exports/example.ts', 'export const value = 42;\n'); + const annotations = new Map([ + [ + filePath, + [ + createAnnotation({ + filePath, + id: 'markdown-export', + comment: 'Check the exported value.', + tags: ['bug-tag', 'docs-tag'], + }), + ], + ], + ]); + const exporter = new AnnotationExporter(annotations, (tagIds) => + (tagIds || []).map(tagId => ({ 'bug-tag': 'Bug', 'docs-tag': 'Docs' }[tagId] || tagId)) + ); + + const markdown = await exporter.exportToMarkdown(); + + assert.ok(markdown.includes('# Code Annotations - workspace')); + assert.ok(markdown.includes('## .test-artifacts/workspace-files/exports/example.ts')); + assert.ok(markdown.includes('### [Open] Annotation 1')); + assert.ok(markdown.includes('**Comment:**\nCheck the exported value.')); + assert.ok(markdown.includes('**Tags:** Bug, Docs')); + }); + + test('exports AI-specific formats and intent filters from current behavior', async () => { + const filePath = await ensureWorkspaceFile('exports/ai.ts', 'export function render() {}\n'); + const annotations = [ + createAnnotation({ + filePath, + id: 'review-annotation', + comment: 'Review this bug fix.', + tags: ['bug'], + resolved: false, + }), + createAnnotation({ + filePath, + id: 'resolved-annotation', + comment: 'Document this branch.', + tags: ['documentation'], + resolved: true, + }), + createAnnotation({ + filePath, + id: 'performance-annotation', + comment: 'Optimize this hot path.', + tags: ['performance'], + resolved: false, + }), + ]; + + const reviewExport = CopilotExporter.exportByIntent(annotations, 'review'); + const bugExport = CopilotExporter.exportByIntent(annotations, 'bugs'); + const chatGptExport = CopilotExporter.exportForAI(annotations, { + format: 'chatgpt', + includeResolved: true, + contextLines: 5, + includeImports: false, + includeFunction: false, + }); + const claudeExport = CopilotExporter.exportForAI(annotations, { + format: 'claude', + includeResolved: true, + contextLines: 5, + includeImports: false, + includeFunction: false, + }); + const genericExport = CopilotExporter.exportForAI(annotations, { + format: 'generic', + includeResolved: true, + contextLines: 5, + includeImports: false, + includeFunction: false, + }); + + assert.ok(reviewExport.includes('Review this bug fix.')); + assert.ok(reviewExport.includes('Optimize this hot path.')); + assert.ok(!reviewExport.includes('Document this branch.')); + + assert.ok(bugExport.includes('Review this bug fix.')); + assert.ok(!bugExport.includes('Optimize this hot path.')); + + assert.ok(chatGptExport.includes('# Code Review Request')); + assert.ok(chatGptExport.includes('### Issue 1')); + + assert.ok(claudeExport.includes('')); + assert.ok(claudeExport.includes('')); + + assert.ok(genericExport.includes('# Code Review Annotations (3 items)')); + assert.ok(genericExport.includes('## Summary')); + }); + + test('honors contributed runtime export settings through the export service', async () => { + const filePath = await ensureWorkspaceFile( + 'exports/runtime-settings.ts', + [ + "import { helper } from './helper';", + '', + 'export function runTask() {', + ' return helper();', + '}', + ].join('\n') + ); + const annotations = new Map([ + [ + filePath, + [ + createAnnotation({ + filePath, + id: 'runtime-settings-annotation', + comment: 'Review the return flow.', + range: new vscode.Range(new vscode.Position(3, 4), new vscode.Position(3, 19)), + text: 'return helper();', + }), + ], + ], + ]); + const service = new AnnotationExportService(annotations); + + await configuration.update('export.contextLines', 0, vscode.ConfigurationTarget.Workspace); + await configuration.update('export.includeImports', false, vscode.ConfigurationTarget.Workspace); + await configuration.update('copilot.autoAttachContext', false, vscode.ConfigurationTarget.Workspace); + await configuration.update('copilot.preferredFormat', 'structured', vscode.ConfigurationTarget.Workspace); + + const quickPrompt = await service.formatAnnotationForCopilot(annotations.get(filePath)![0]); + const prepared = service.prepareCopilotExport(service.getAllAnnotations()); + const aiPrepared = service.prepareAIExport(service.getAllAnnotations(), 'chatgpt', true); + + assert.ok(quickPrompt.includes("I'm reviewing this code and found an issue.")); + assert.ok(prepared.content.includes('')); + assert.strictEqual(prepared.language, 'xml'); + assert.ok(aiPrepared.content.includes('### Issue 1')); + + await configuration.update('copilot.autoAttachContext', true, vscode.ConfigurationTarget.Workspace); + + const detailedPrompt = await service.formatAnnotationForCopilot(annotations.get(filePath)![0]); + + assert.ok(!detailedPrompt.includes("import { helper } from './helper';")); + assert.ok(detailedPrompt.includes('4: return helper();')); + }); +}); \ No newline at end of file diff --git a/src/test/suite/manager.test.ts b/src/test/suite/manager.test.ts new file mode 100644 index 0000000..90d85e8 --- /dev/null +++ b/src/test/suite/manager.test.ts @@ -0,0 +1,114 @@ +import * as assert from 'assert'; +import { AnnotationManager } from '../../managers'; +import { AnnotationStorageFile, TagStorageFile } from '../../types'; +import { + clearTestWorkspace, + createAnnotation, + createCustomTag, + createTestContext, + ensureWorkspaceFile, + getStoragePaths, + readJson, + toStoredAnnotation, + writeJson, +} from './testUtils'; + +suite('AnnotationManager', () => { + teardown(async () => { + await clearTestWorkspace(); + }); + + test('migrates loaded tag names to ids and persists canonical tags', async () => { + await clearTestWorkspace(); + + const filePath = await ensureWorkspaceFile('manager-migration.ts', 'const answer = 42;\n'); + const { annotationsPath, customTagsPath } = getStoragePaths(); + const customTags = [ + createCustomTag({ + id: 'needs-review', + name: 'Needs Review', + metadata: { priority: 'high', color: '#42A5F5' }, + }), + ]; + const legacyAnnotation = createAnnotation({ + filePath, + id: 'migration-target', + tags: ['Needs Review', 'needs-review', ' '], + comment: 'Normalize this tag set.', + }); + + await writeJson(customTagsPath, { + schemaVersion: 1, + customTags, + } satisfies TagStorageFile); + await writeJson(annotationsPath, { + schemaVersion: 1, + workspaceAnnotations: { + [filePath]: [toStoredAnnotation(legacyAnnotation)], + }, + } satisfies AnnotationStorageFile); + + const manager = new AnnotationManager(createTestContext()); + await manager.ready; + + const loadedAnnotation = manager.getAnnotationsForFile(filePath)[0]; + assert.deepStrictEqual(loadedAnnotation.tags, ['needs-review']); + assert.strictEqual(manager.resolveTagLabel('needs-review'), 'Needs Review'); + assert.strictEqual(manager.getAnnotationPriority(loadedAnnotation), 'high'); + + const persisted = await readJson(annotationsPath); + assert.deepStrictEqual(persisted.workspaceAnnotations[filePath][0].tags, ['needs-review']); + + manager.dispose(); + }); + + test('preserves annotation tag ids across tag rename and delete operations', async () => { + await clearTestWorkspace(); + + const filePath = await ensureWorkspaceFile('manager-tags.ts', 'const review = true;\n'); + const { annotationsPath, customTagsPath } = getStoragePaths(); + const customTags = [ + createCustomTag({ id: 'bug-tag', name: 'Bug Tag' }), + createCustomTag({ id: 'critical-tag', name: 'Critical Tag', metadata: { priority: 'critical' } }), + ]; + const storedAnnotation = createAnnotation({ + filePath, + id: 'tagged-annotation', + tags: ['bug-tag', 'critical-tag'], + comment: 'Keep ids stable while labels change.', + }); + + await writeJson(customTagsPath, { + schemaVersion: 1, + customTags, + } satisfies TagStorageFile); + await writeJson(annotationsPath, { + schemaVersion: 1, + workspaceAnnotations: { + [filePath]: [toStoredAnnotation(storedAnnotation)], + }, + } satisfies AnnotationStorageFile); + + const manager = new AnnotationManager(createTestContext()); + await manager.ready; + + const annotation = manager.getAnnotationsForFile(filePath)[0]; + assert.strictEqual(manager.getAnnotationPriority(annotation), 'critical'); + assert.strictEqual( + manager.getAnnotationPriority(createAnnotation({ filePath, id: 'default-priority', tags: ['bug-tag'] })), + 'medium' + ); + + const updated = await manager.updateCustomTag('bug-tag', 'Bugs'); + assert.strictEqual(updated?.name, 'Bugs'); + assert.deepStrictEqual(annotation.tags, ['bug-tag', 'critical-tag']); + assert.strictEqual(manager.resolveTagLabel('bug-tag'), 'Bugs'); + + const deleted = await manager.deleteCustomTag('bug-tag'); + assert.strictEqual(deleted, true); + assert.deepStrictEqual(annotation.tags, ['bug-tag', 'critical-tag']); + assert.strictEqual(manager.resolveTagLabel('bug-tag'), 'bug-tag'); + + manager.dispose(); + }); +}); \ No newline at end of file diff --git a/src/test/suite/sidebarWebview.test.ts b/src/test/suite/sidebarWebview.test.ts new file mode 100644 index 0000000..eaeb2ff --- /dev/null +++ b/src/test/suite/sidebarWebview.test.ts @@ -0,0 +1,155 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { AnnotationTagOption } from '../../types'; +import { SidebarWebview } from '../../ui/sidebarWebview'; +import { createAnnotation, getWorkspaceRoot } from './testUtils'; + +class FakeWebview { + public html = ''; + public options: vscode.WebviewOptions | undefined; + public readonly cspSource = 'vscode-webview://test'; + public readonly postedMessages: Array<{ command: string; [key: string]: unknown }> = []; + private messageHandler: ((message: unknown) => Promise | void) | undefined; + + asWebviewUri(uri: vscode.Uri): vscode.Uri { + return uri; + } + + onDidReceiveMessage(handler: (message: unknown) => Promise | void): vscode.Disposable { + this.messageHandler = handler; + return new vscode.Disposable(() => { + this.messageHandler = undefined; + }); + } + + async postMessage(message: { command: string; [key: string]: unknown }): Promise { + this.postedMessages.push(message); + return true; + } + + async dispatch(message: { command: string; [key: string]: unknown }): Promise { + await this.messageHandler?.(message); + } +} + +class FakeWebviewView { + public visible = true; + + constructor(public readonly webview: FakeWebview) {} + + onDidChangeVisibility(_listener: () => void): vscode.Disposable { + return new vscode.Disposable(() => undefined); + } +} + +suite('SidebarWebview', () => { + test('loads initial annotations, tags, and filter state into the webview', () => { + const filePath = `${getWorkspaceRoot()}\\sample.ts`; + const annotation = createAnnotation({ filePath, id: 'sidebar-initial' }); + const tags: AnnotationTagOption[] = [{ id: 'bug-tag', label: 'Bug', priority: 'high' }]; + const annotationManager = { + getAllAnnotations: () => [annotation], + getTagOptions: () => tags, + }; + const sidebar = new SidebarWebview(vscode.Uri.file(getWorkspaceRoot()), annotationManager as never); + const webview = new FakeWebview(); + + sidebar.resolveWebviewView( + new FakeWebviewView(webview) as unknown as vscode.WebviewView, + {} as vscode.WebviewViewResolveContext, + {} as vscode.CancellationToken + ); + + assert.strictEqual(webview.postedMessages.length, 3); + assert.deepStrictEqual(webview.postedMessages.map(message => message.command), [ + 'updateAnnotations', + 'tagsUpdated', + 'filterStateUpdated', + ]); + assert.ok(webview.html.includes('sidebar-webview.js')); + }); + + test('routes critical sidebar messages to the annotation manager and tracks filters', async () => { + const filePath = `${getWorkspaceRoot()}\\sidebar-actions.ts`; + const annotation = createAnnotation({ + filePath, + id: 'sidebar-actions', + tags: ['bug-tag'], + comment: 'Original sidebar comment.', + }); + const calls = { + toggles: [] as Array<{ id: string; filePath: string }>, + removals: [] as Array<{ id: string; filePath: string }>, + edits: [] as Array<{ id: string; filePath: string; comment: string; tags: string[]; color?: string }>, + resolveAll: 0, + deleteResolved: 0, + }; + const annotationManager = { + getAllAnnotations: () => [annotation], + getTagOptions: () => [{ id: 'bug-tag', label: 'Bug' }], + toggleResolvedStatus: async (id: string, targetFilePath: string) => { + calls.toggles.push({ id, filePath: targetFilePath }); + annotation.resolved = !annotation.resolved; + }, + removeAnnotation: async (id: string, targetFilePath: string) => { + calls.removals.push({ id, filePath: targetFilePath }); + }, + editAnnotation: async ( + id: string, + targetFilePath: string, + comment: string, + tags: string[], + color?: string + ) => { + calls.edits.push({ id, filePath: targetFilePath, comment, tags, color }); + annotation.comment = comment; + annotation.tags = [...tags]; + annotation.color = color; + }, + resolveAll: async () => { + calls.resolveAll += 1; + return 1; + }, + deleteResolved: async () => { + calls.deleteResolved += 1; + return 1; + }, + }; + const sidebar = new SidebarWebview(vscode.Uri.file(getWorkspaceRoot()), annotationManager as never); + const webview = new FakeWebview(); + + sidebar.resolveWebviewView( + new FakeWebviewView(webview) as unknown as vscode.WebviewView, + {} as vscode.WebviewViewResolveContext, + {} as vscode.CancellationToken + ); + + await webview.dispatch({ + command: 'filterStateChanged', + filters: { status: 'resolved', tag: 'bug-tag', search: 'sidebar' }, + }); + assert.deepStrictEqual(sidebar.getFilterState(), { + status: 'resolved', + tag: 'bug-tag', + search: 'sidebar', + groupBy: 'file', + }); + + await webview.dispatch({ command: 'toggleResolved', id: 'sidebar-actions' }); + await webview.dispatch({ command: 'addTag', id: 'sidebar-actions', tag: 'docs-tag' }); + await webview.dispatch({ command: 'removeTag', id: 'sidebar-actions', tag: 'bug-tag' }); + await webview.dispatch({ command: 'manageTags', id: 'sidebar-actions', tags: ['docs-tag', 'security-tag'] }); + await webview.dispatch({ command: 'resolveAll' }); + await webview.dispatch({ command: 'deleteResolved' }); + await webview.dispatch({ command: 'delete', id: 'sidebar-actions' }); + + assert.deepStrictEqual(calls.toggles, [{ id: 'sidebar-actions', filePath }]); + assert.deepStrictEqual(calls.removals, [{ id: 'sidebar-actions', filePath }]); + assert.strictEqual(calls.edits.length, 3); + assert.deepStrictEqual(calls.edits[0].tags, ['bug-tag', 'docs-tag']); + assert.deepStrictEqual(calls.edits[1].tags, ['docs-tag']); + assert.deepStrictEqual(calls.edits[2].tags, ['docs-tag', 'security-tag']); + assert.strictEqual(calls.resolveAll, 1); + assert.strictEqual(calls.deleteResolved, 1); + }); +}); \ No newline at end of file diff --git a/src/test/suite/storage.test.ts b/src/test/suite/storage.test.ts new file mode 100644 index 0000000..da7632e --- /dev/null +++ b/src/test/suite/storage.test.ts @@ -0,0 +1,144 @@ +import * as assert from 'assert'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { AnnotationStorageManager } from '../../managers'; +import { AnnotationStorageFile, TagStorageFile } from '../../types'; +import { + clearTestWorkspace, + createAnnotation, + createCustomTag, + createTestContext, + ensureWorkspaceFile, + getStoragePaths, + writeJson, +} from './testUtils'; + +suite('AnnotationStorageManager', () => { + setup(async () => { + await clearTestWorkspace(); + }); + + teardown(async () => { + await clearTestWorkspace(); + }); + + test('round-trips annotations and custom tags', async () => { + const annotations = new Map(); + const storage = new AnnotationStorageManager(annotations, createTestContext()); + const filePath = await ensureWorkspaceFile('storage-roundtrip.ts', 'const answer = 42;\n'); + const annotation = createAnnotation({ + filePath, + id: 'storage-roundtrip', + comment: 'Persist this annotation.', + tags: ['needs-review'], + priority: 'high', + color: '#42A5F5', + }); + const customTags = [ + createCustomTag({ + id: 'needs-review', + name: 'Needs Review', + metadata: { priority: 'high', color: '#42A5F5' }, + }), + ]; + + annotations.set(filePath, [annotation]); + + await storage.saveAnnotations(); + await storage.saveCustomTags(customTags); + + annotations.clear(); + + const annotationLoad = await storage.loadAnnotations(); + const loadedTags = await storage.loadCustomTags(); + + assert.strictEqual(annotationLoad.needsSave, false); + assert.strictEqual(loadedTags.needsSave, false); + assert.deepStrictEqual(loadedTags.tags, customTags); + + const loadedAnnotation = annotations.get(filePath)?.[0]; + assert.ok(loadedAnnotation, 'Expected the saved annotation to load back from storage.'); + assert.strictEqual(loadedAnnotation.id, annotation.id); + assert.strictEqual(loadedAnnotation.comment, annotation.comment); + assert.strictEqual(loadedAnnotation.timestamp.toISOString(), annotation.timestamp.toISOString()); + assert.deepStrictEqual(loadedAnnotation.tags, ['needs-review']); + assert.strictEqual(loadedAnnotation.priority, 'high'); + assert.strictEqual(loadedAnnotation.color, '#42A5F5'); + }); + + test('quarantines a corrupted annotation storage file and resets in-memory state', async () => { + const annotations = new Map(); + const storage = new AnnotationStorageManager(annotations, createTestContext()); + const { storageDir, annotationsPath } = getStoragePaths(); + + await storage.ensureProjectStorage(); + await fs.mkdir(storageDir, { recursive: true }); + await fs.writeFile(annotationsPath, '{ invalid json', 'utf-8'); + + const result = await storage.loadAnnotations(); + const storageEntries = await fs.readdir(storageDir); + + assert.strictEqual(result.needsSave, false); + assert.strictEqual(annotations.size, 0); + assert.ok( + storageEntries.some(name => /^annotations\.corrupt-.*\.json$/.test(name)), + 'Expected a quarantined copy of the corrupt annotations file.' + ); + assert.ok(!storageEntries.includes('annotations.json')); + }); + + test('loads legacy schemas and signals that they should be rewritten', async () => { + const annotations = new Map(); + const storage = new AnnotationStorageManager(annotations, createTestContext()); + const filePath = await ensureWorkspaceFile('legacy-schema.ts', 'const legacy = true;\n'); + const legacyAnnotation = createAnnotation({ + filePath, + id: 'legacy-annotation', + comment: 'Legacy payload.', + tags: ['legacy-tag'], + }); + const { annotationsPath, customTagsPath } = getStoragePaths(); + + await storage.ensureProjectStorage(); + + await writeJson(annotationsPath, { + workspaceAnnotations: { + [filePath]: [ + { + ...legacyAnnotation, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + timestamp: legacyAnnotation.timestamp.toISOString(), + }, + ], + }, + }); + await writeJson(customTagsPath, [createCustomTag({ id: 'legacy-tag', name: 'Legacy Tag' })]); + + const annotationLoad = await storage.loadAnnotations(); + const tagLoad = await storage.loadCustomTags(); + + assert.strictEqual(annotationLoad.needsSave, true); + assert.strictEqual(tagLoad.needsSave, true); + + const loadedAnnotation = annotations.get(filePath)?.[0]; + assert.ok(loadedAnnotation, 'Expected the legacy annotation payload to load.'); + assert.deepStrictEqual(loadedAnnotation.tags, ['legacy-tag']); + }); + + test('detects project storage from the active workspace context instead of a hardcoded root index', async () => { + const annotations = new Map(); + const storage = new AnnotationStorageManager(annotations, createTestContext()); + const filePath = await ensureWorkspaceFile('storage-detection.ts', 'const rootAware = true;\n'); + const editor = await vscode.window.showTextDocument(vscode.Uri.file(filePath)); + + await storage.ensureProjectStorage(); + + assert.ok(storage.getStorageDirectory().endsWith('.annotative')); + + await editor.hide(); + }); +}); \ No newline at end of file diff --git a/src/test/suite/testUtils.ts b/src/test/suite/testUtils.ts new file mode 100644 index 0000000..27042ab --- /dev/null +++ b/src/test/suite/testUtils.ts @@ -0,0 +1,97 @@ +import * as assert from 'assert'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { Annotation, AnnotationTag, StoredAnnotation } from '../../types'; + +export function getWorkspaceRoot(): string { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(workspaceFolder, 'Expected the VS Code test workspace to be open.'); + return workspaceFolder.uri.fsPath; +} + +export function createTestContext(): vscode.ExtensionContext { + const workspaceRoot = getWorkspaceRoot(); + + return { + subscriptions: [], + extensionUri: vscode.Uri.file(workspaceRoot), + asAbsolutePath: (relativePath: string) => path.join(workspaceRoot, relativePath), + } as unknown as vscode.ExtensionContext; +} + +export async function clearTestWorkspace(): Promise { + const workspaceRoot = getWorkspaceRoot(); + await fs.rm(path.join(workspaceRoot, '.annotative'), { recursive: true, force: true }); + await fs.rm(path.join(workspaceRoot, '.test-artifacts'), { recursive: true, force: true }); +} + +export async function ensureWorkspaceFile(relativePath: string, contents: string): Promise { + const filePath = path.join(getWorkspaceRoot(), '.test-artifacts', 'workspace-files', relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents, 'utf-8'); + return filePath; +} + +export async function writeJson(filePath: string, payload: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8'); +} + +export async function readJson(filePath: string): Promise { + return JSON.parse(await fs.readFile(filePath, 'utf-8')) as T; +} + +export function getStoragePaths(): { storageDir: string; annotationsPath: string; customTagsPath: string } { + const storageDir = path.join(getWorkspaceRoot(), '.annotative'); + return { + storageDir, + annotationsPath: path.join(storageDir, 'annotations.json'), + customTagsPath: path.join(storageDir, 'customTags.json'), + }; +} + +export function createAnnotation(overrides: Partial & { filePath: string }): Annotation { + return { + id: overrides.id ?? 'annotation-1', + filePath: overrides.filePath, + range: overrides.range ?? new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 12)), + text: overrides.text ?? 'const answer = 42;', + comment: overrides.comment ?? 'Review this code path.', + author: overrides.author ?? 'Test User', + timestamp: overrides.timestamp ?? new Date('2026-03-24T12:00:00.000Z'), + resolved: overrides.resolved ?? false, + tags: overrides.tags ?? [], + priority: overrides.priority, + color: overrides.color ?? '#ffc107', + aiConversations: overrides.aiConversations, + }; +} + +export function toStoredAnnotation(annotation: Annotation): StoredAnnotation { + return { + ...annotation, + range: { + start: { + line: annotation.range.start.line, + character: annotation.range.start.character, + }, + end: { + line: annotation.range.end.line, + character: annotation.range.end.character, + }, + }, + timestamp: annotation.timestamp.toISOString(), + tags: annotation.tags ? [...annotation.tags] : undefined, + }; +} + +export function createCustomTag(overrides: Partial & { id: string; name: string }): AnnotationTag { + return { + id: overrides.id, + name: overrides.name, + category: overrides.category ?? 'issue', + metadata: overrides.metadata, + isPreset: overrides.isPreset ?? false, + }; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 82c223f..ea5604e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,8 +33,21 @@ export interface AnnotationTag { isPreset: boolean; // Always false for user-created tags } -// Backward compatibility: accept both string and AnnotationTag -export type Tag = string | AnnotationTag; +export interface AnnotationTagOption { + id: string; + label: string; + color?: string; + priority?: TagPriority; +} + +export interface AnnotationAnchor { + selectedText: string; + prefixContext: string; + suffixContext: string; + selectedTextHash: string; + normalizedTextHash: string; + contextHash: string; +} export interface Annotation { id: string; @@ -45,10 +58,11 @@ export interface Annotation { author: string; timestamp: Date; resolved: boolean; // Resolution status - open (false) or resolved (true) - tags?: Tag[]; + tags?: string[]; priority?: TagPriority; color?: string; // Hex color code - user's visual preference only aiConversations?: AIConversation[]; + anchor?: AnnotationAnchor; } export interface AnnotationDecoration { @@ -57,9 +71,39 @@ export interface AnnotationDecoration { } export interface AnnotationStorage { + schemaVersion: number; workspaceAnnotations: { [filePath: string]: Annotation[] }; } +export interface StoredPosition { + line: number; + character: number; +} + +export interface StoredRange { + start: StoredPosition; + end: StoredPosition; +} + +export type LegacyStoredTag = string | AnnotationTag; +export type Tag = string | AnnotationTag; + +export interface StoredAnnotation extends Omit { + range: StoredRange; + timestamp: string; + tags?: LegacyStoredTag[]; +} + +export interface AnnotationStorageFile { + schemaVersion: number; + workspaceAnnotations: { [filePath: string]: StoredAnnotation[] }; +} + +export interface TagStorageFile { + schemaVersion: number; + customTags: AnnotationTag[]; +} + // Tag management export interface TagRegistry { customTags: Map; @@ -97,4 +141,4 @@ export interface AnnotationStatistics { resolved: number; unresolved: number; byFile: Map; -} \ No newline at end of file +} diff --git a/src/ui/annotationProvider.ts b/src/ui/annotationProvider.ts index fa0ca7f..a9ed073 100644 --- a/src/ui/annotationProvider.ts +++ b/src/ui/annotationProvider.ts @@ -13,11 +13,10 @@ import { groupByFile, groupByTag, groupByStatus, - groupByFolder, - groupByPriority + groupByFolder } from './grouping'; -export type GroupBy = 'file' | 'tag' | 'status' | 'folder' | 'priority'; +export type GroupBy = 'file' | 'tag' | 'status' | 'folder'; export class AnnotationProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); @@ -126,11 +125,9 @@ export class AnnotationProvider implements vscode.TreeDataProvider { if (this.groupBy === 'file') { return Promise.resolve(groupByFile(filteredAnnotations) as TreeItem[]); } else if (this.groupBy === 'tag') { - return Promise.resolve(groupByTag(filteredAnnotations) as TreeItem[]); + return Promise.resolve(groupByTag(filteredAnnotations, (tagId) => this.annotationManager.resolveTagLabel(tagId)) as TreeItem[]); } else if (this.groupBy === 'folder') { return Promise.resolve(groupByFolder(filteredAnnotations) as TreeItem[]); - } else if (this.groupBy === 'priority') { - return Promise.resolve(groupByPriority(filteredAnnotations) as TreeItem[]); } else { return Promise.resolve(groupByStatus(filteredAnnotations) as TreeItem[]); } @@ -142,7 +139,8 @@ export class AnnotationProvider implements vscode.TreeDataProvider { new AnnotationItem( annotation, vscode.TreeItemCollapsibleState.None, - this.isAnnotationSelected(annotation.id) + this.isAnnotationSelected(annotation.id), + this.annotationManager.resolveTagLabels(annotation.tags) ) ); return Promise.resolve(annotationItems as TreeItem[]); @@ -188,7 +186,8 @@ export class AnnotationProvider implements vscode.TreeDataProvider { new AnnotationItem( annotation, vscode.TreeItemCollapsibleState.None, - this.isAnnotationSelected(annotation.id) + this.isAnnotationSelected(annotation.id), + this.annotationManager.resolveTagLabels(annotation.tags) ) ); return Promise.resolve(annotationItems as TreeItem[]); diff --git a/src/ui/filtering/filterAnnotations.ts b/src/ui/filtering/filterAnnotations.ts index 01bee6f..8b6dc1d 100644 --- a/src/ui/filtering/filterAnnotations.ts +++ b/src/ui/filtering/filterAnnotations.ts @@ -26,10 +26,7 @@ export function filterAnnotations( if (!annotation.tags) { return false; } - const hasTag = annotation.tags.some(tag => { - const tagId = typeof tag === 'string' ? tag : tag.id; - return tagId === filterTag; - }); + const hasTag = annotation.tags.some(tagId => tagId === filterTag); if (!hasTag) { return false; } @@ -41,10 +38,7 @@ export function filterAnnotations( const commentMatch = annotation.comment.toLowerCase().includes(searchLower); const textMatch = annotation.text.toLowerCase().includes(searchLower); const authorMatch = annotation.author.toLowerCase().includes(searchLower); - const tagMatch = annotation.tags?.some(tag => { - const tagName = typeof tag === 'string' ? tag : tag.name; - return tagName.toLowerCase().includes(searchLower); - }); + const tagMatch = annotation.tags?.some(tagId => tagId.toLowerCase().includes(searchLower)); if (!commentMatch && !textMatch && !authorMatch && !tagMatch) { return false; diff --git a/src/ui/grouping/groupByTag.ts b/src/ui/grouping/groupByTag.ts index 2e41948..978fb0e 100644 --- a/src/ui/grouping/groupByTag.ts +++ b/src/ui/grouping/groupByTag.ts @@ -5,7 +5,10 @@ import { GroupCategoryItem } from '../treeItems'; /** * Groups annotations by tag */ -export function groupByTag(annotations: Annotation[]): GroupCategoryItem[] { +export function groupByTag( + annotations: Annotation[], + resolveTagLabel: (tagId: string) => string = (tagId) => tagId +): GroupCategoryItem[] { const tagGroups = new Map(); const untaggedAnnotations: Annotation[] = []; @@ -13,8 +16,7 @@ export function groupByTag(annotations: Annotation[]): GroupCategoryItem[] { if (!annotation.tags || annotation.tags.length === 0) { untaggedAnnotations.push(annotation); } else { - annotation.tags.forEach(tag => { - const tagId = typeof tag === 'string' ? tag : tag.id; + annotation.tags.forEach(tagId => { if (!tagGroups.has(tagId)) { tagGroups.set(tagId, []); } @@ -28,9 +30,9 @@ export function groupByTag(annotations: Annotation[]): GroupCategoryItem[] { // Add tagged groups Array.from(tagGroups.entries()) .sort((a, b) => a[0].localeCompare(b[0])) - .forEach(([tag, anns]) => { + .forEach(([tagId, anns]) => { tagItems.push(new GroupCategoryItem( - `${tag} (${anns.length})`, + `${resolveTagLabel(tagId)} (${anns.length})`, anns, vscode.TreeItemCollapsibleState.Expanded )); diff --git a/src/ui/sidebarWebview.ts b/src/ui/sidebarWebview.ts index 0dc67db..c0b6bae 100644 --- a/src/ui/sidebarWebview.ts +++ b/src/ui/sidebarWebview.ts @@ -2,7 +2,9 @@ import * as vscode from 'vscode'; import { AnnotationManager } from '../managers'; import { Annotation } from '../types'; import { generateWebviewHtml } from './webview'; -import { WebviewMessage } from './webview/types'; +import { FilterState, WebviewMessage } from './webview/types'; + +type SidebarFilterState = FilterState; /** * Sidebar Webview Provider @@ -15,6 +17,12 @@ export class SidebarWebview implements vscode.WebviewViewProvider { private view?: vscode.WebviewView; private annotationManager: AnnotationManager; private disposables: vscode.Disposable[] = []; + private filterState: SidebarFilterState = { + status: 'all', + tag: 'all', + search: '', + groupBy: 'file' + }; constructor(private extensionUri: vscode.Uri, annotationManager: AnnotationManager) { this.annotationManager = annotationManager; @@ -90,6 +98,33 @@ export class SidebarWebview implements vscode.WebviewViewProvider { } } + getFilterState(): SidebarFilterState { + return { ...this.filterState }; + } + + setFilterState(nextState: Partial) { + this.filterState = { + ...this.filterState, + ...nextState, + }; + + if (this.view) { + this.postMessage({ + command: 'filterStateUpdated', + filters: this.getFilterState(), + }); + } + } + + clearFilters() { + this.setFilterState({ + status: 'all', + tag: 'all', + search: '', + groupBy: 'file', + }); + } + /** * Get the webview URI for a resource file */ @@ -124,16 +159,21 @@ export class SidebarWebview implements vscode.WebviewViewProvider { */ private loadInitialData(webview: vscode.Webview) { const annotations = this.annotationManager.getAllAnnotations(); - webview.postMessage({ + this.postMessage({ command: 'updateAnnotations', annotations, }); - const tags = this.annotationManager.getAllTags(); - webview.postMessage({ + const tags = this.annotationManager.getTagOptions(); + this.postMessage({ command: 'tagsUpdated', tags, }); + + this.postMessage({ + command: 'filterStateUpdated', + filters: this.getFilterState(), + }); } /** @@ -147,6 +187,15 @@ export class SidebarWebview implements vscode.WebviewViewProvider { this.loadInitialData(webview); break; + case 'filterStateChanged': + if (message.filters) { + this.filterState = { + ...this.filterState, + ...message.filters, + }; + } + break; + case 'navigate': if (message.annotation) { await this.handleNavigate(message.annotation); @@ -156,57 +205,44 @@ export class SidebarWebview implements vscode.WebviewViewProvider { case 'toggleResolved': if (typeof message.id === 'string') { await this.handleToggleResolved(message.id); - // Refresh webview after changes - setTimeout(() => this.loadInitialData(webview), 100); } break; case 'delete': if (typeof message.id === 'string') { await this.handleDelete(message.id); - // Refresh webview after changes - setTimeout(() => this.loadInitialData(webview), 100); } break; case 'edit': if (message.annotation) { await this.handleEdit(message.annotation); - // Refresh webview after changes - setTimeout(() => this.loadInitialData(webview), 100); } break; case 'resolveAll': await this.handleResolveAll(); - // Refresh webview after changes - setTimeout(() => this.loadInitialData(webview), 100); break; case 'deleteResolved': await this.handleDeleteResolved(); - // Refresh webview after changes - setTimeout(() => this.loadInitialData(webview), 100); break; case 'addTag': if (message.id && message.tag) { await this.handleAddTag(message.id, message.tag); - setTimeout(() => this.loadInitialData(webview), 100); } break; case 'removeTag': if (message.id && message.tag) { await this.handleRemoveTag(message.id, message.tag); - setTimeout(() => this.loadInitialData(webview), 100); } break; case 'manageTags': if (message.id && message.tags) { await this.handleManageTags(message.id, message.tags); - setTimeout(() => this.loadInitialData(webview), 100); } break; } @@ -225,13 +261,15 @@ export class SidebarWebview implements vscode.WebviewViewProvider { try { const uri = vscode.Uri.file(annotation.filePath); const doc = await vscode.workspace.openTextDocument(uri); + await this.annotationManager.rebaseAnnotationsForDocument(doc); const editor = await vscode.window.showTextDocument(doc); + const resolvedAnnotation = this.annotationManager.getAnnotation(annotation.id, annotation.filePath) || annotation; const range = new vscode.Range( - annotation.range.start.line, - annotation.range.start.character, - annotation.range.end.line, - annotation.range.end.character + resolvedAnnotation.range.start.line, + resolvedAnnotation.range.start.character, + resolvedAnnotation.range.end.line, + resolvedAnnotation.range.end.character ); editor.selection = new vscode.Selection(range.start, range.end); @@ -291,9 +329,7 @@ export class SidebarWebview implements vscode.WebviewViewProvider { }); if (newComment !== undefined && newComment.trim().length > 0) { - const currentTags = annotation.tags?.map((t) => - typeof t === 'string' ? t : t.id - ) || []; + const currentTags = annotation.tags || []; await this.annotationManager.editAnnotation( annotation.id, @@ -339,9 +375,7 @@ export class SidebarWebview implements vscode.WebviewViewProvider { const annotation = allAnnotations.find((a) => a.id === id); if (annotation) { - const currentTags = annotation.tags?.map((t) => - typeof t === 'string' ? t : t.id - ) || []; + const currentTags = annotation.tags || []; if (!currentTags.includes(tag)) { const updatedTags = [...currentTags, tag]; @@ -368,9 +402,7 @@ export class SidebarWebview implements vscode.WebviewViewProvider { const annotation = allAnnotations.find((a) => a.id === id); if (annotation) { - const currentTags = annotation.tags?.map((t) => - typeof t === 'string' ? t : t.id - ) || []; + const currentTags = annotation.tags || []; const updatedTags = currentTags.filter(t => t !== tag); await this.annotationManager.editAnnotation( @@ -420,6 +452,12 @@ export class SidebarWebview implements vscode.WebviewViewProvider { return text; } + private postMessage(message: { command: string; [key: string]: unknown }) { + if (this.view) { + this.view.webview.postMessage(message); + } + } + /** * Cleanup disposables */ diff --git a/src/ui/treeItems/annotationItem.ts b/src/ui/treeItems/annotationItem.ts index 16dd368..2e10955 100644 --- a/src/ui/treeItems/annotationItem.ts +++ b/src/ui/treeItems/annotationItem.ts @@ -8,14 +8,15 @@ export class AnnotationItem extends vscode.TreeItem { constructor( public readonly annotation: Annotation, public readonly collapsibleState: vscode.TreeItemCollapsibleState, - public readonly isSelected: boolean = false + public readonly isSelected: boolean = false, + tagLabels: string[] = annotation.tags || [] ) { const statusIcon = annotation.resolved ? 'โœ“' : 'โ—‹'; const selectedIcon = isSelected ? 'โ˜‘ ' : 'โ˜ '; const preview = annotation.comment.length > 45 ? annotation.comment.substring(0, 42) + '...' : annotation.comment; - const tagsLabel = annotation.tags && annotation.tags.length > 0 ? ` [${annotation.tags.join(', ')}]` : ''; + const tagsLabel = tagLabels.length > 0 ? ` [${tagLabels.join(', ')}]` : ''; super(`${selectedIcon}${statusIcon} Line ${annotation.range.start.line + 1}: ${preview}${tagsLabel}`, collapsibleState); diff --git a/src/ui/webview/htmlBuilder.ts b/src/ui/webview/htmlBuilder.ts index 3e16f27..61dcb4e 100644 --- a/src/ui/webview/htmlBuilder.ts +++ b/src/ui/webview/htmlBuilder.ts @@ -56,7 +56,6 @@ export function generateWebviewHtml(options: HtmlBuilderOptions): string { - diff --git a/src/ui/webview/types.ts b/src/ui/webview/types.ts index 732a962..5e26351 100644 --- a/src/ui/webview/types.ts +++ b/src/ui/webview/types.ts @@ -3,7 +3,7 @@ * Defines all message interfaces for communication between webview and extension */ -import { Annotation } from '../../types'; +import { Annotation, AnnotationTagOption } from '../../types'; /** * Messages sent FROM the webview TO the extension @@ -18,7 +18,8 @@ export type WebviewToExtensionCommand = | 'edit' | 'addTag' | 'removeTag' - | 'manageTags'; + | 'manageTags' + | 'filterStateChanged'; export interface WebviewMessage { command: WebviewToExtensionCommand; @@ -39,13 +40,15 @@ export type ExtensionToWebviewCommand = | 'tagsUpdated' | 'annotationAdded' | 'annotationRemoved' - | 'annotationUpdated'; + | 'annotationUpdated' + | 'filterStateUpdated'; export interface ExtensionMessage { command: ExtensionToWebviewCommand; annotations?: Annotation[]; - tags?: string[]; + tags?: AnnotationTagOption[]; annotation?: Annotation; + filters?: FilterState; [key: string]: unknown; } @@ -56,7 +59,7 @@ export interface FilterState { status: 'all' | 'resolved' | 'unresolved'; tag: string; search: string; - groupBy: 'file' | 'tag' | 'status' | 'folder' | 'priority'; + groupBy: 'file' | 'tag' | 'status' | 'folder'; } /** diff --git a/src/ui/webview/utils.ts b/src/ui/webview/utils.ts index c3466cb..7cb2cb1 100644 --- a/src/ui/webview/utils.ts +++ b/src/ui/webview/utils.ts @@ -38,10 +38,7 @@ export function filterAnnotations( // Filter by tag if (filters.tag && filters.tag !== 'all') { - const hasTag = ann.tags?.some((t) => { - const tagId = typeof t === 'string' ? t : t.id; - return tagId === filters.tag; - }); + const hasTag = ann.tags?.some((tagId) => tagId === filters.tag); if (!hasTag) { return false; } @@ -79,15 +76,12 @@ export function groupAnnotations( key = parts.length > 1 ? parts[parts.length - 2] : 'Root'; } else if (groupBy === 'tag') { if (ann.tags && ann.tags.length > 0) { - const tagId = typeof ann.tags[0] === 'string' ? ann.tags[0] : ann.tags[0].id; - key = tagId; + key = ann.tags[0]; } else { key = 'Untagged'; } } else if (groupBy === 'status') { key = ann.resolved ? 'Resolved' : 'Unresolved'; - } else if (groupBy === 'priority') { - key = ann.priority || 'Default'; } if (!groups[key]) { @@ -118,8 +112,7 @@ export function extractTags(annotations: Annotation[]): string[] { const tags = new Set(); annotations.forEach((ann) => { if (ann.tags && Array.isArray(ann.tags)) { - ann.tags.forEach((t) => { - const tagId = typeof t === 'string' ? t : t.id; + ann.tags.forEach((tagId) => { tags.add(tagId); }); } diff --git a/src/utils/workspaceContext.ts b/src/utils/workspaceContext.ts new file mode 100644 index 0000000..6ebdd36 --- /dev/null +++ b/src/utils/workspaceContext.ts @@ -0,0 +1,106 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { Annotation } from '../types'; + +function getActiveWorkspaceFolder(): vscode.WorkspaceFolder | undefined { + const activeUri = vscode.window.activeTextEditor?.document.uri; + return activeUri ? vscode.workspace.getWorkspaceFolder(activeUri) : undefined; +} + +function getWorkspaceFolderOrder(folder: vscode.WorkspaceFolder): number { + return vscode.workspace.workspaceFolders?.findIndex(candidate => candidate.uri.toString() === folder.uri.toString()) ?? 0; +} + +export function getWorkspaceFolderForFilePath(filePath: string): vscode.WorkspaceFolder | undefined { + return vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath)); +} + +export function getRelativePathForFile(filePath: string): string { + return vscode.workspace.asRelativePath(vscode.Uri.file(filePath), false); +} + +export function getPreferredWorkspaceFolder(): vscode.WorkspaceFolder | undefined { + return getActiveWorkspaceFolder() || vscode.workspace.workspaceFolders?.[0]; +} + +export function resolveWorkspaceFolderForAnnotations(annotations: readonly Annotation[]): vscode.WorkspaceFolder | undefined { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + return undefined; + } + + const folderCounts = new Map(); + + annotations.forEach(annotation => { + const folder = getWorkspaceFolderForFilePath(annotation.filePath); + if (!folder) { + return; + } + + const key = folder.uri.toString(); + const existing = folderCounts.get(key); + if (existing) { + existing.count += 1; + return; + } + + folderCounts.set(key, { folder, count: 1 }); + }); + + if (folderCounts.size === 0) { + return getPreferredWorkspaceFolder(); + } + + const activeFolder = getActiveWorkspaceFolder(); + if (activeFolder && folderCounts.has(activeFolder.uri.toString())) { + return activeFolder; + } + + return [...folderCounts.values()] + .sort((left, right) => { + if (right.count !== left.count) { + return right.count - left.count; + } + + return getWorkspaceFolderOrder(left.folder) - getWorkspaceFolderOrder(right.folder); + })[0]?.folder; +} + +export function getWorkspaceNameForAnnotations(annotations: readonly Annotation[]): string { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + return 'Unknown Workspace'; + } + + const folderUris = new Set( + annotations + .map(annotation => getWorkspaceFolderForFilePath(annotation.filePath)?.uri.toString()) + .filter((uri): uri is string => typeof uri === 'string') + ); + + if (folderUris.size > 1) { + return 'Multi-root Workspace'; + } + + if (folderUris.size === 1) { + const folder = resolveWorkspaceFolderForAnnotations(annotations); + return folder?.name || 'Unknown Workspace'; + } + + return getPreferredWorkspaceFolder()?.name || 'Unknown Workspace'; +} + +export function findWorkspaceFolderContainingChild(childPath: string): vscode.WorkspaceFolder | undefined { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + return undefined; + } + + const activeFolder = getActiveWorkspaceFolder(); + if (activeFolder && fs.existsSync(path.join(activeFolder.uri.fsPath, childPath))) { + return activeFolder; + } + + return workspaceFolders.find(folder => fs.existsSync(path.join(folder.uri.fsPath, childPath))); +} \ No newline at end of file