From 2b7574073e6c153d5d2551bd71b5853eeddf93bd Mon Sep 17 00:00:00 2001 From: Bryan Shea Date: Tue, 24 Mar 2026 13:48:14 -0400 Subject: [PATCH 1/8] Synchronizes filter state between sidebar and extension Implements a unified filter state management system by introducing two-way communication of filter updates between the sidebar webview and the extension. Updates commands and UI to use the new filter state, enabling consistent filtering, grouping, and searching across the sidebar and command palette. Removes obsolete context menu configuration and refactors command registration for clarity. Improves user experience by ensuring filter changes remain in sync regardless of where they are triggered. --- .husky/pre-commit | 3 -- media/sidebar-webview.js | 48 ++++++++++++++++++++++---- package.json | 37 -------------------- src/commands/bulk.ts | 4 +++ src/commands/filters.ts | 31 +++++++++-------- src/commands/index.ts | 6 ++-- src/extension.ts | 7 +--- src/ui/sidebarWebview.ts | 74 +++++++++++++++++++++++++++++++--------- src/ui/webview/types.ts | 7 ++-- 9 files changed, 128 insertions(+), 89 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index f0d7328..fbcac30 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,6 +1,3 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - # Pre-commit hook: lint and format validation echo "Running pre-commit checks..." diff --git a/media/sidebar-webview.js b/media/sidebar-webview.js index 7b17a9e..efe1de4 100644 --- a/media/sidebar-webview.js +++ b/media/sidebar-webview.js @@ -15,6 +15,7 @@ const state = { filters: { status: 'all', tag: 'all', + search: '', groupBy: 'file', }, }; @@ -243,7 +244,7 @@ function calculateStats(annotations) { } function updateTagFilter(filterElement, tags, currentValue) { - const currentTag = filterElement.value; + const selectedTag = currentValue || 'all'; filterElement.innerHTML = ''; tags.forEach((tag) => { @@ -253,13 +254,34 @@ function updateTagFilter(filterElement, tags, currentValue) { 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'); @@ -497,6 +519,7 @@ function setupEventListeners() { } elements.statusFilter?.addEventListener('change', (e) => { state.filters.status = e.target.value; + notifyFilterStateChanged(); applyFiltersAndRender(); }); @@ -505,6 +528,7 @@ function setupEventListeners() { } elements.tagFilter?.addEventListener('change', (e) => { state.filters.tag = e.target.value; + notifyFilterStateChanged(); applyFiltersAndRender(); }); @@ -513,6 +537,7 @@ function setupEventListeners() { } elements.groupBySelect?.addEventListener('change', (e) => { state.filters.groupBy = e.target.value; + notifyFilterStateChanged(); applyFiltersAndRender(); }); @@ -564,6 +589,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 +609,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); @@ -593,12 +620,21 @@ function handleTagsUpdated(tags) { try { // Store available tags in state for use by tag picker state.availableTags = tags || []; - updateTagFilter(elements.tagFilter, tags, state.filters.tag); + 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); diff --git a/package.json b/package.json index c7344aa..cd56386 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": [ diff --git a/src/commands/bulk.ts b/src/commands/bulk.ts index f6c888a..56658bc 100644 --- a/src/commands/bulk.ts +++ b/src/commands/bulk.ts @@ -21,6 +21,10 @@ export function registerBulkCommands( ) { const { annotationManager, annotationProvider, sidebarWebview, ANNOTATION_COLORS } = cmdContext; + if (!annotationProvider) { + return {}; + } + // Command: Bulk tag annotations const bulkTagCommand = vscode.commands.registerCommand( 'annotative.bulkTag', diff --git a/src/commands/filters.ts b/src/commands/filters.ts index cd0229f..673f278 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,7 +52,7 @@ 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)' : '' }, @@ -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'); } ); @@ -127,10 +125,13 @@ 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)' : '' }, + { label: 'By Priority', value: 'priority' as const, description: currentGroupBy === 'priority' ? '(current)' : '' } ]; const selected = await vscode.window.showQuickPick(options, { @@ -138,7 +139,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/extension.ts b/src/extension.ts index 782c545..2e96faa 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 @@ -61,7 +58,6 @@ export function activate(context: vscode.ExtensionContext) { // Create command context const cmdContext: CommandContext = { annotationManager, - annotationProvider, sidebarWebview, ANNOTATION_COLORS }; @@ -71,7 +67,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)) diff --git a/src/ui/sidebarWebview.ts b/src/ui/sidebarWebview.ts index 0dc67db..658000b 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({ + 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; } @@ -420,6 +456,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/webview/types.ts b/src/ui/webview/types.ts index 732a962..2631734 100644 --- a/src/ui/webview/types.ts +++ b/src/ui/webview/types.ts @@ -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[]; annotation?: Annotation; + filters?: FilterState; [key: string]: unknown; } From 6e5e84f3c6b47019eadc934d5705ef1e73162d72 Mon Sep 17 00:00:00 2001 From: Bryan Shea Date: Tue, 24 Mar 2026 13:48:35 -0400 Subject: [PATCH 2/8] refactor: consolidate commit hooks for improved validation and consistency --- .husky/commit-msg | 7 ++++++- .husky/pre-commit | 17 +++++++++++------ .husky/pre-push | 21 +++++++++++++-------- 3 files changed, 30 insertions(+), 15 deletions(-) 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 fbcac30..0744f32 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,12 +1,17 @@ -# Pre-commit hook: lint and format validation -echo "Running pre-commit checks..." +#!/bin/sh +. "$(dirname "$0")/_/annotative-hook-config.sh" -# Run ESLint +if ! annotative_should_enforce_hooks; then + echo "Skipping pre-commit checks on branch $(annotative_current_branch)" + exit 0 +fi + +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" From 900e30b89d205a9ab73cbd8a3ccc8617a462f56d Mon Sep 17 00:00:00 2001 From: Bryan Shea Date: Tue, 24 Mar 2026 14:14:36 -0400 Subject: [PATCH 3/8] Refactor annotation management and storage - Removed redundant tag conversion functions and streamlined tag handling in annotations. - Updated bulk command registration to use new tag structure. - Enhanced filter commands to utilize resolved tag labels. - Simplified sidebar initialization by removing migration choice. - Improved annotation export functionality to include resolved tag labels. - Refactored annotation storage to support schema versioning and improved error handling. - Updated UI components to reflect changes in tag management and filtering. - Removed priority grouping from UI and related logic. - Enhanced type definitions for better clarity and maintainability. --- media/sidebar-webview.js | 44 ++-- src/commands/annotation.ts | 38 +-- src/commands/bulk.ts | 20 +- src/commands/filters.ts | 5 +- src/commands/sidebar.ts | 20 +- src/commands/tags.ts | 35 +-- src/managers/annotationExporter.ts | 10 +- src/managers/annotationManager.ts | 177 ++++++++----- src/managers/annotationStorage.ts | 360 +++++++++++++++++++------- src/types.ts | 42 ++- src/ui/annotationProvider.ts | 15 +- src/ui/filtering/filterAnnotations.ts | 10 +- src/ui/grouping/groupByTag.ts | 12 +- src/ui/sidebarWebview.ts | 14 +- src/ui/treeItems/annotationItem.ts | 5 +- src/ui/webview/htmlBuilder.ts | 1 - src/ui/webview/types.ts | 6 +- src/ui/webview/utils.ts | 13 +- 18 files changed, 528 insertions(+), 299 deletions(-) diff --git a/media/sidebar-webview.js b/media/sidebar-webview.js index efe1de4..c12fbb8 100644 --- a/media/sidebar-webview.js +++ b/media/sidebar-webview.js @@ -12,6 +12,7 @@ const vscode = acquireVsCodeApi(); const state = { annotations: [], availableTags: [], + tagLabels: {}, filters: { status: 'all', tag: 'all', @@ -97,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 @@ -208,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; } @@ -225,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); }); } @@ -234,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 { @@ -250,7 +251,7 @@ function updateTagFilter(filterElement, tags, currentValue) { tags.forEach((tag) => { const option = document.createElement('option'); option.value = tag; - option.textContent = tag; + option.textContent = getTagLabel(tag); filterElement.appendChild(option); }); @@ -325,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; @@ -420,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]) { @@ -618,8 +615,9 @@ function handleUpdateAnnotations(annotations) { function handleTagsUpdated(tags) { try { - // Store available tags in state for use by tag picker - state.availableTags = tags || []; + 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); @@ -710,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/src/commands/annotation.ts b/src/commands/annotation.ts index 8fc8b87..7c177c3 100644 --- a/src/commands/annotation.ts +++ b/src/commands/annotation.ts @@ -4,18 +4,10 @@ */ 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 @@ -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); } } @@ -249,16 +248,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 +296,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 56658bc..6538bd3 100644 --- a/src/commands/bulk.ts +++ b/src/commands/bulk.ts @@ -4,17 +4,9 @@ */ 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 @@ -48,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 @@ -56,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, @@ -141,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/filters.ts b/src/commands/filters.ts index 673f278..e672881 100644 --- a/src/commands/filters.ts +++ b/src/commands/filters.ts @@ -57,7 +57,7 @@ export function registerFilterCommands( 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)' : '' })) @@ -130,8 +130,7 @@ export function registerFilterCommands( { 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)' : '' }, - { label: 'By Priority', value: 'priority' as const, description: currentGroupBy === 'priority' ? '(current)' : '' } + { label: 'By Folder', value: 'folder' as const, description: currentGroupBy === 'folder' ? '(current)' : '' } ]; const selected = await vscode.window.showQuickPick(options, { diff --git a/src/commands/sidebar.ts b/src/commands/sidebar.ts index 2cf2d3d..5eb4c44 100644 --- a/src/commands/sidebar.ts +++ b/src/commands/sidebar.ts @@ -38,27 +38,11 @@ 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.'); } 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/managers/annotationExporter.ts b/src/managers/annotationExporter.ts index a7e9fc9..d506dad 100644 --- a/src/managers/annotationExporter.ts +++ b/src/managers/annotationExporter.ts @@ -5,7 +5,10 @@ import { Annotation, ExportData } from '../types'; * 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 @@ -59,7 +62,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 +98,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..686b8c1 100644 --- a/src/managers/annotationManager.ts +++ b/src/managers/annotationManager.ts @@ -1,5 +1,15 @@ 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 { AnnotationCRUD } from './annotationCRUD'; import { AnnotationDecorations } from './annotationDecorations'; @@ -7,8 +17,7 @@ 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(); @@ -25,20 +34,20 @@ export class AnnotationManager { 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.exporter = new AnnotationExporter(this.annotations, (tagIds) => this.resolveTagLabels(tagIds)); - this.initialize(); + void 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(); - // ============== Tag Management ============== + if (annotationLoad.needsSave || migratedAnnotations) { + await this.storage.saveAnnotations(); + } + } getTagManager(): TagManager { return this.tagManager; @@ -52,6 +61,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 +122,6 @@ export class AnnotationManager { return this.tagManager.suggestTagsFromComment(comment); } - // ============== CRUD Operations ============== - async addAnnotation( editor: vscode.TextEditor, range: vscode.Range, @@ -147,27 +189,14 @@ 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 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(); } @@ -184,15 +213,11 @@ export class AnnotationManager { return this.exporter.getStatistics(); } - // ============== Decoration & Styling ============== - updateDecorations(editor: vscode.TextEditor): void { const fileAnnotations = this.exporter.getAnnotationsForFile(editor.document.uri.fsPath); this.decorations.updateDecorations(editor, fileAnnotations); } - // ============== Export & Import ============== - async exportAnnotations(): Promise { return this.exporter.exportAnnotations(); } @@ -201,50 +226,40 @@ export class AnnotationManager { return this.exporter.exportToMarkdown(); } - // ============== Project Storage ============== - - /** - * 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 +275,53 @@ 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; + }); + }); + + return changed; + } + 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..e84ad57 100644 --- a/src/managers/annotationStorage.ts +++ b/src/managers/annotationStorage.ts @@ -1,110 +1,113 @@ -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, + StoredAnnotation, + TagPriority, + TagStorageFile, +} from '../types'; + +const STORAGE_SCHEMA_VERSION = 1; + +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; 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'); - } + if (!workspaceFolders || workspaceFolders.length === 0) { + return; + } + + const annotativeDir = path.join(workspaceFolders[0].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 + 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(workspaceFolders[0].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 workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + throw new Error('No workspace folder open'); + } + + const annotativeDir = path.join(workspaceFolders[0].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 +115,253 @@ export class AnnotationStorageManager { this.detectProjectStorage(); } - /** - * Load annotations from storage - * Only loads if project storage exists - */ - async loadAnnotations(): Promise { + 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(); - 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(); - fs.writeFileSync(this.customTagsPath, JSON.stringify(tags, null, 2)); + const storage: TagStorageFile = { + schemaVersion: STORAGE_SCHEMA_VERSION, + customTags: tags, + }; + + 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, + }; + } + + 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), + }; + } + + 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 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/types.ts b/src/types.ts index 82c223f..1600206 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,8 +33,12 @@ 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 Annotation { id: string; @@ -45,7 +49,7 @@ 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[]; @@ -57,9 +61,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 +131,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 658000b..55281be 100644 --- a/src/ui/sidebarWebview.ts +++ b/src/ui/sidebarWebview.ts @@ -164,7 +164,7 @@ export class SidebarWebview implements vscode.WebviewViewProvider { annotations, }); - const tags = this.annotationManager.getAllTags(); + const tags = this.annotationManager.getTagOptions(); this.postMessage({ command: 'tagsUpdated', tags, @@ -327,9 +327,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, @@ -375,9 +373,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]; @@ -404,9 +400,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( 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 2631734..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 @@ -46,7 +46,7 @@ export type ExtensionToWebviewCommand = export interface ExtensionMessage { command: ExtensionToWebviewCommand; annotations?: Annotation[]; - tags?: string[]; + tags?: AnnotationTagOption[]; annotation?: Annotation; filters?: FilterState; [key: string]: unknown; @@ -59,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); }); } From 2d075d3575dd2e75f6ac691241b0522ef9c52444 Mon Sep 17 00:00:00 2001 From: Bryan Shea Date: Tue, 24 Mar 2026 14:45:31 -0400 Subject: [PATCH 4/8] feat: implement regression test harness and enhance annotation management --- package.json | 2 +- scripts/run-vscode-tests.mjs | 102 +++++++++++++++++ src/managers/annotationCRUD.ts | 16 +-- src/managers/annotationManager.ts | 3 +- src/test/fixtures/workspace/sample.ts | 1 + src/test/suite/crud.test.ts | 107 ++++++++++++++++++ src/test/suite/exporter.test.ts | 102 +++++++++++++++++ src/test/suite/manager.test.ts | 114 +++++++++++++++++++ src/test/suite/sidebarWebview.test.ts | 155 ++++++++++++++++++++++++++ src/test/suite/storage.test.ts | 130 +++++++++++++++++++++ src/test/suite/testUtils.ts | 97 ++++++++++++++++ 11 files changed, 817 insertions(+), 12 deletions(-) create mode 100644 scripts/run-vscode-tests.mjs create mode 100644 src/test/fixtures/workspace/sample.ts create mode 100644 src/test/suite/crud.test.ts create mode 100644 src/test/suite/exporter.test.ts create mode 100644 src/test/suite/manager.test.ts create mode 100644 src/test/suite/sidebarWebview.test.ts create mode 100644 src/test/suite/storage.test.ts create mode 100644 src/test/suite/testUtils.ts diff --git a/package.json b/package.json index cd56386..6925045 100644 --- a/package.json +++ b/package.json @@ -426,7 +426,7 @@ "watch-tests": "tsc -p . -w --outDir out", "pretest": "npm run compile-tests && npm run compile && npm run lint", "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", 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/managers/annotationCRUD.ts b/src/managers/annotationCRUD.ts index 9c6a369..03b603e 100644 --- a/src/managers/annotationCRUD.ts +++ b/src/managers/annotationCRUD.ts @@ -205,20 +205,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/annotationManager.ts b/src/managers/annotationManager.ts index 686b8c1..109abe9 100644 --- a/src/managers/annotationManager.ts +++ b/src/managers/annotationManager.ts @@ -28,6 +28,7 @@ export class AnnotationManager { private exporter: AnnotationExporter; private onDidChangeAnnotationsEmitter = new vscode.EventEmitter(); public readonly onDidChangeAnnotations = this.onDidChangeAnnotationsEmitter.event; + public readonly ready: Promise; constructor(private context: vscode.ExtensionContext) { this.tagManager = new TagManager(); @@ -36,7 +37,7 @@ export class AnnotationManager { this.crud = new AnnotationCRUD(this.annotations, this.decorations, this.storage); this.exporter = new AnnotationExporter(this.annotations, (tagIds) => this.resolveTagLabels(tagIds)); - void this.initialize(); + this.ready = this.initialize(); } private async initialize(): Promise { 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/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..f558a12 --- /dev/null +++ b/src/test/suite/exporter.test.ts @@ -0,0 +1,102 @@ +import * as assert from 'assert'; +import { AnnotationExporter } from '../../managers'; +import { CopilotExporter } from '../../copilotExporter'; +import { Annotation } from '../../types'; +import { createAnnotation, ensureWorkspaceFile } from './testUtils'; + +suite('Exporters', () => { + 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')); + }); +}); \ 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..e613a0f --- /dev/null +++ b/src/test/suite/storage.test.ts @@ -0,0 +1,130 @@ +import * as assert from 'assert'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +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']); + }); +}); \ 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 From 3d5b44cf558c9bdbf27b80f9d89ba7ca3e14e2c7 Mon Sep 17 00:00:00 2001 From: Bryan Shea Date: Tue, 24 Mar 2026 14:56:47 -0400 Subject: [PATCH 5/8] Refactor Copilot integration and export services - Removed deprecated settings from package.json related to Copilot export. - Introduced AnnotationExportService to handle export logic and settings. - Updated annotation commands to utilize the new export service for Copilot integration. - Enhanced export functionality to support different formats and settings. - Improved workspace folder detection for annotation storage. - Added utility functions for workspace context management. - Updated tests to validate new export service behavior and settings. --- package.json | 15 -- src/commands/annotation.ts | 24 ++- src/commands/export.ts | 165 ++++++++-------- src/copilotExporter.ts | 113 +++++------ src/extension.ts | 11 +- src/managers/annotationExportService.ts | 180 ++++++++++++++++++ src/managers/annotationExporter.ts | 18 +- src/managers/annotationManager.ts | 26 +-- src/managers/annotationStorage.ts | 33 +++- src/managers/exportSupport.ts | 22 +++ src/managers/index.ts | 1 + .../fixtures/workspace/.vscode/settings.json | 2 + src/test/suite/exporter.test.ts | 60 ++++++ src/test/suite/storage.test.ts | 14 ++ src/utils/workspaceContext.ts | 106 +++++++++++ 15 files changed, 597 insertions(+), 193 deletions(-) create mode 100644 src/managers/annotationExportService.ts create mode 100644 src/managers/exportSupport.ts create mode 100644 src/test/fixtures/workspace/.vscode/settings.json create mode 100644 src/utils/workspaceContext.ts diff --git a/package.json b/package.json index 6925045..0115a23 100644 --- a/package.json +++ b/package.json @@ -371,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, @@ -401,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, diff --git a/src/commands/annotation.ts b/src/commands/annotation.ts index 7c177c3..c844ec5 100644 --- a/src/commands/annotation.ts +++ b/src/commands/annotation.ts @@ -6,13 +6,13 @@ import * as vscode from 'vscode'; import { AnnotationItem } from '../ui'; import { CommandContext } from './index'; -import { CopilotExporter } from '../copilotExporter'; 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( @@ -176,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); + } + }); } } } 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/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 2e96faa..c15e118 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -48,11 +48,12 @@ 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 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 d506dad..9cff845 100644 --- a/src/managers/annotationExporter.ts +++ b/src/managers/annotationExporter.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { Annotation, ExportData } from '../types'; +import { getRelativePathForFile, getWorkspaceNameForAnnotations, groupAnnotationsByFile } from './exportSupport'; /** * Export and utility functions for annotations @@ -14,13 +15,12 @@ export class AnnotationExporter { * 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) }; } @@ -38,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) => { diff --git a/src/managers/annotationManager.ts b/src/managers/annotationManager.ts index 109abe9..5331c31 100644 --- a/src/managers/annotationManager.ts +++ b/src/managers/annotationManager.ts @@ -13,8 +13,8 @@ import { import { TagManager } from '../tags'; 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. @@ -25,7 +25,7 @@ export class AnnotationManager { private crud: AnnotationCRUD; private decorations: AnnotationDecorations; private storage: AnnotationStorageManager; - private exporter: AnnotationExporter; + private exportService: AnnotationExportService; private onDidChangeAnnotationsEmitter = new vscode.EventEmitter(); public readonly onDidChangeAnnotations = this.onDidChangeAnnotationsEmitter.event; public readonly ready: Promise; @@ -35,7 +35,7 @@ export class AnnotationManager { 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, (tagIds) => this.resolveTagLabels(tagIds)); + this.exportService = new AnnotationExportService(this.annotations, (tagIds) => this.resolveTagLabels(tagIds)); this.ready = this.initialize(); } @@ -191,7 +191,7 @@ export class AnnotationManager { } getAllTags(): string[] { - const usedTags = this.exporter.getAllTags(); + 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]); @@ -199,32 +199,36 @@ export class AnnotationManager { } getUsedTags(): string[] { - return this.exporter.getAllTags(); + return this.exportService.getAllTags(); } getAnnotationsForFile(filePath: string): Annotation[] { - return this.exporter.getAnnotationsForFile(filePath); + return this.exportService.getAnnotationsForFile(filePath); } getAllAnnotations(): Annotation[] { - return this.exporter.getAllAnnotations(); + return this.exportService.getAllAnnotations(); } getStatistics(): AnnotationStatistics { - return this.exporter.getStatistics(); + return this.exportService.getStatistics(); } updateDecorations(editor: vscode.TextEditor): void { - const fileAnnotations = this.exporter.getAnnotationsForFile(editor.document.uri.fsPath); + const fileAnnotations = this.exportService.getAnnotationsForFile(editor.document.uri.fsPath); this.decorations.updateDecorations(editor, fileAnnotations); } async exportAnnotations(): Promise { - return this.exporter.exportAnnotations(); + return this.exportService.exportAnnotations(); } async exportToMarkdown(): Promise { - return this.exporter.exportToMarkdown(); + return this.exportService.exportToMarkdown(); + } + + getExportService(): AnnotationExportService { + return this.exportService; } isProjectStorageActive(): boolean { diff --git a/src/managers/annotationStorage.ts b/src/managers/annotationStorage.ts index e84ad57..ae1089c 100644 --- a/src/managers/annotationStorage.ts +++ b/src/managers/annotationStorage.ts @@ -9,6 +9,11 @@ import { TagPriority, TagStorageFile, } from '../types'; +import { + findWorkspaceFolderContainingChild, + getPreferredWorkspaceFolder, + resolveWorkspaceFolderForAnnotations, +} from '../utils/workspaceContext'; const STORAGE_SCHEMA_VERSION = 1; @@ -49,12 +54,12 @@ export class AnnotationStorageManager { } private detectProjectStorage(): void { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { + const storageFolder = findWorkspaceFolderContainingChild('.annotative'); + if (!storageFolder) { return; } - const annotativeDir = path.join(workspaceFolders[0].uri.fsPath, '.annotative'); + const annotativeDir = path.join(storageFolder.uri.fsPath, '.annotative'); if (!fs.existsSync(annotativeDir)) { return; } @@ -77,12 +82,12 @@ export class AnnotationStorageManager { return; } - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { + const workspaceFolder = this.resolveStorageWorkspaceFolder(); + if (!workspaceFolder) { throw new Error('No workspace folder open'); } - const annotativeDir = path.join(workspaceFolders[0].uri.fsPath, '.annotative'); + const annotativeDir = path.join(workspaceFolder.uri.fsPath, '.annotative'); if (!fs.existsSync(annotativeDir)) { await fs.promises.mkdir(annotativeDir, { recursive: true }); @@ -97,12 +102,12 @@ export class AnnotationStorageManager { } async initializeProjectStorage(): Promise { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { + const workspaceFolder = this.resolveStorageWorkspaceFolder(); + if (!workspaceFolder) { throw new Error('No workspace folder open'); } - const annotativeDir = path.join(workspaceFolders[0].uri.fsPath, '.annotative'); + const annotativeDir = path.join(workspaceFolder.uri.fsPath, '.annotative'); const existedBefore = fs.existsSync(annotativeDir); await this.ensureProjectStorage(); return !existedBefore && !!this.projectStorageDir; @@ -115,6 +120,16 @@ export class AnnotationStorageManager { this.detectProjectStorage(); } + 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)) { 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/suite/exporter.test.ts b/src/test/suite/exporter.test.ts index f558a12..f694c32 100644 --- a/src/test/suite/exporter.test.ts +++ b/src/test/suite/exporter.test.ts @@ -1,10 +1,21 @@ 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([ @@ -99,4 +110,53 @@ suite('Exporters', () => { 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/storage.test.ts b/src/test/suite/storage.test.ts index e613a0f..da7632e 100644 --- a/src/test/suite/storage.test.ts +++ b/src/test/suite/storage.test.ts @@ -1,6 +1,7 @@ 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 { @@ -127,4 +128,17 @@ suite('AnnotationStorageManager', () => { 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/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 From 60b0f630c26a5de4f133de65476757c343651379 Mon Sep 17 00:00:00 2001 From: Bryan Shea Date: Tue, 24 Mar 2026 15:18:41 -0400 Subject: [PATCH 6/8] feat: implement annotation anchoring and reattachment logic with associated tests --- src/commands/filters.ts | 6 +- src/extension.ts | 28 ++ src/managers/annotationAnchors.ts | 474 ++++++++++++++++++++++++++++++ src/managers/annotationCRUD.ts | 13 +- src/managers/annotationManager.ts | 106 ++++++- src/managers/annotationStorage.ts | 94 ++++-- src/test/suite/anchoring.test.ts | 224 ++++++++++++++ src/types.ts | 10 + src/ui/sidebarWebview.ts | 10 +- 9 files changed, 937 insertions(+), 28 deletions(-) create mode 100644 src/managers/annotationAnchors.ts create mode 100644 src/test/suite/anchoring.test.ts diff --git a/src/commands/filters.ts b/src/commands/filters.ts index e672881..e864f03 100644 --- a/src/commands/filters.ts +++ b/src/commands/filters.ts @@ -110,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}`); } diff --git a/src/extension.ts b/src/extension.ts index c15e118..254d835 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -82,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 03b603e..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); } diff --git a/src/managers/annotationManager.ts b/src/managers/annotationManager.ts index 5331c31..4cb3a2e 100644 --- a/src/managers/annotationManager.ts +++ b/src/managers/annotationManager.ts @@ -11,6 +11,7 @@ import { TagSuggestion, } from '../types'; import { TagManager } from '../tags'; +import { reattachAnnotation } from './annotationAnchors'; import { AnnotationCRUD } from './annotationCRUD'; import { AnnotationDecorations } from './annotationDecorations'; import { AnnotationExportService } from './annotationExportService'; @@ -27,6 +28,7 @@ export class AnnotationManager { private storage: AnnotationStorageManager; private exportService: AnnotationExportService; private onDidChangeAnnotationsEmitter = new vscode.EventEmitter(); + private persistenceScheduled = false; public readonly onDidChangeAnnotations = this.onDidChangeAnnotationsEmitter.event; public readonly ready: Promise; @@ -44,8 +46,9 @@ export class AnnotationManager { await this.loadCustomTags(); const annotationLoad = await this.storage.loadAnnotations(); const migratedAnnotations = this.normalizeLoadedAnnotations(); + const rebasedAnnotations = await this.rebaseStoredAnnotations(); - if (annotationLoad.needsSave || migratedAnnotations) { + if (annotationLoad.needsSave || migratedAnnotations || rebasedAnnotations) { await this.storage.saveAnnotations(); } } @@ -203,22 +206,47 @@ export class AnnotationManager { } getAnnotationsForFile(filePath: string): Annotation[] { + const openDocument = this.findOpenDocument(filePath); + if (openDocument && this.rebaseAnnotationsForText(filePath, openDocument.getText())) { + this.scheduleAnnotationPersistence(); + } + return this.exportService.getAnnotationsForFile(filePath); } getAllAnnotations(): Annotation[] { + this.refreshOpenDocuments(); return this.exportService.getAllAnnotations(); } getStatistics(): AnnotationStatistics { + this.refreshOpenDocuments(); return this.exportService.getStatistics(); } updateDecorations(editor: vscode.TextEditor): void { + 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); } + 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.exportService.exportAnnotations(); } @@ -315,12 +343,88 @@ export class AnnotationManager { } 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(); } diff --git a/src/managers/annotationStorage.ts b/src/managers/annotationStorage.ts index ae1089c..dffbdc0 100644 --- a/src/managers/annotationStorage.ts +++ b/src/managers/annotationStorage.ts @@ -5,6 +5,7 @@ import { Annotation, AnnotationStorageFile, AnnotationTag, + AnnotationAnchor, StoredAnnotation, TagPriority, TagStorageFile, @@ -15,7 +16,7 @@ import { resolveWorkspaceFolderForAnnotations, } from '../utils/workspaceContext'; -const STORAGE_SCHEMA_VERSION = 1; +const STORAGE_SCHEMA_VERSION = 2; export interface LoadAnnotationsResult { needsSave: boolean; @@ -44,6 +45,7 @@ export class AnnotationStorageManager { private storageFilePath = ''; private customTagsPath = ''; private projectStorageDir: string | undefined; + private writeQueue: Promise = Promise.resolve(); constructor( private annotations: Map, @@ -158,19 +160,21 @@ export class AnnotationStorageManager { async saveAnnotations(): Promise { try { - await this.ensureProjectStorage(); - - 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)) - ]) - ) - }; + await this.enqueueWrite(async () => { + await this.ensureProjectStorage(); + + 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)) + ]) + ) + }; - await this.writeJsonAtomically(this.storageFilePath, storage); + await this.writeJsonAtomically(this.storageFilePath, storage); + }); } catch (error) { console.error('Failed to save annotations:', error); vscode.window.showErrorMessage('Failed to save annotations'); @@ -197,14 +201,16 @@ export class AnnotationStorageManager { async saveCustomTags(tags: AnnotationTag[]): Promise { try { - await this.ensureProjectStorage(); + await this.enqueueWrite(async () => { + await this.ensureProjectStorage(); - const storage: TagStorageFile = { - schemaVersion: STORAGE_SCHEMA_VERSION, - customTags: tags, - }; + const storage: TagStorageFile = { + schemaVersion: STORAGE_SCHEMA_VERSION, + customTags: tags, + }; - await this.writeJsonAtomically(this.customTagsPath, storage); + await this.writeJsonAtomically(this.customTagsPath, storage); + }); } catch (error) { console.error('Failed to save custom tags:', error); vscode.window.showErrorMessage('Failed to save custom tags'); @@ -226,6 +232,7 @@ export class AnnotationStorageManager { }, timestamp: annotation.timestamp.toISOString(), tags: annotation.tags ? [...annotation.tags] : undefined, + anchor: this.serializeAnchor(annotation.anchor), }; } @@ -248,6 +255,49 @@ export class AnnotationStorageManager { 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, }; } @@ -365,6 +415,12 @@ export class AnnotationStorageManager { } } + 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; 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/types.ts b/src/types.ts index 1600206..ea5604e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,6 +40,15 @@ export interface AnnotationTagOption { priority?: TagPriority; } +export interface AnnotationAnchor { + selectedText: string; + prefixContext: string; + suffixContext: string; + selectedTextHash: string; + normalizedTextHash: string; + contextHash: string; +} + export interface Annotation { id: string; filePath: string; @@ -53,6 +62,7 @@ export interface Annotation { priority?: TagPriority; color?: string; // Hex color code - user's visual preference only aiConversations?: AIConversation[]; + anchor?: AnnotationAnchor; } export interface AnnotationDecoration { diff --git a/src/ui/sidebarWebview.ts b/src/ui/sidebarWebview.ts index 55281be..c0b6bae 100644 --- a/src/ui/sidebarWebview.ts +++ b/src/ui/sidebarWebview.ts @@ -261,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); From 7748aa7b3bdad41c484a9abd9cd1460bcdf15a42 Mon Sep 17 00:00:00 2001 From: Bryan Shea Date: Tue, 24 Mar 2026 15:40:26 -0400 Subject: [PATCH 7/8] chore: update package.json scripts and dependencies - Added a new quality check script to run compile, lint, tests, and compliance check. - Renamed the publish script for clarity. - Updated devDependencies: - @types/node to ^22.19.15 - @typescript-eslint/eslint-plugin and parser to ^8.57.2 - @vscode/vsce to ^3.7.1 - eslint to ^9.39.4 - webpack to ^5.105.4 fix: improve cleanup in changeset-enhanced script - Moved file cleanup to a finally block to ensure temp file is deleted. - Updated commit message prompt to clarify release process. --- .github/workflows/auto-release.yml | 173 +++-------- .vscodeignore | 2 + CONTRIBUTING.md | 21 +- MIGRATION.md | 62 ++-- README.md | 123 ++++---- package-lock.json | 476 +++++++++++++++-------------- package.json | 17 +- scripts/changeset-enhanced.js | 9 +- 8 files changed, 416 insertions(+), 467 deletions(-) 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/.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/CONTRIBUTING.md b/CONTRIBUTING.md index 24a988e..39ded95 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Annotative is a VS Code extension for code annotation and review workflows. This ### Prerequisites -- Node.js 22.x or higher +- Node.js 20.x or 22.x - VS Code 1.105.0 or higher - Git @@ -37,7 +37,7 @@ Annotative is a VS Code extension for code annotation and review workflows. This ### Project Structure -``` +```text src/ commands/ # Command implementations managers/ # Core business logic @@ -57,6 +57,8 @@ scripts/ # Build and deployment scripts - `npm run compile` - Compile TypeScript once - `npm run watch` - Compile in watch mode for development - `npm run package` - Build production bundle with webpack +- `npm run quality` - Run compile, lint, tests, and compliance checks +- `npm run release:check` - Run the full local release verification flow ### Testing @@ -79,7 +81,7 @@ npm test # Run tests Or run all checks at once: ```bash -npm run pretest +npm run quality ``` ## Code Standards @@ -102,7 +104,7 @@ npm run pretest Follow conventional commit format: -``` +```text feat: add new feature fix: resolve bug docs: update documentation @@ -208,14 +210,15 @@ Documentation files: Releases are managed by maintainers. The process includes: -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 and commit the release version and changelog on `main` +2. Run `npm run release:check` locally if you are preparing the release commit +3. Trigger the manual GitHub Actions release workflow from `main` +4. Let the workflow validate, package, publish, and tag that already-versioned commit Contributors do not need to manage versions or releases. +Do not use local shortcuts that stage everything, bypass hooks, or push directly to `main` as part of normal contribution flow. + ## Questions For questions about contributing: diff --git a/MIGRATION.md b/MIGRATION.md index 0e1603e..b106608 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -22,9 +22,9 @@ This guide helps you upgrade from Annotative v1.5.0 to v2.0.0, which introduces **What Changed:** -- 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 +- Annotative now writes annotations and custom tags into a `.annotative/` folder in your workspace root +- The folder is created on first annotation or custom-tag save, or manually with `Annotative: Initialize Storage` +- Global-storage migration is not automatic in the current implementation - All annotations are project-scoped **Why:** @@ -69,17 +69,30 @@ Since preset tags no longer exist, you'll need to create custom tags for the one ### Step 3: Initialize Project Storage -**For existing annotations:** +**Create the project storage folder:** + +You can initialize storage in either of these ways: + +1. Run `Annotative: Initialize Storage` +2. Or create your first annotation or custom tag and let Annotative create `.annotative/` on save + +The folder will contain: + +- `README.md` - Storage and version-control guidance +- `annotations.json` - Saved annotations +- `customTags.json` - Saved custom tags + +### Step 4: Handle legacy v1.5.0 data 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` +1. Do not assume they will appear automatically after upgrading +2. The current extension code does not import legacy global-state annotations for you +3. Recreate the annotations in the project workspace, or manually move/export the data before relying on v2.0.0 project storage -**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. +**Note:** If migrated or shared annotations still reference old preset tag names, edit them and map them onto your new custom tags. -### Step 4: Update Existing Annotations +### Step 5: Update Existing Annotations If you have annotations that reference old preset tags: @@ -93,9 +106,9 @@ If you have annotations that reference old preset 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 +- Review annotations file-by-file and replace old preset tag references with project-specific tags -### Step 5: Share with Your Team +### Step 6: Share with Your Team Now that annotations are stored in `.annotative/`: @@ -129,9 +142,9 @@ git add .annotative/ Your `.annotative/` folder will contain: +- `README.md` - Storage guidance created by Annotative - `annotations.json` - All annotations - `customTags.json` - Your custom tag definitions -- `.gitignore` - Placeholder (can be deleted) ### Optional: Exclude Annotations from Version Control @@ -139,7 +152,7 @@ If you want to keep annotations private: 1. Add to your project's `.gitignore`: - ``` + ```gitignore .annotative/ ``` @@ -159,23 +172,24 @@ In v2.0.0, when you use "Add from Template": This makes templates more flexible and useful with your custom tag system. -### No Manual Storage Initialization +### Manual Storage Initialization Is Still Available -You no longer need to run "Initialize Storage": +You do not need to run `Annotative: Initialize Storage`, but the command still exists if you want the folder created before your first save. -- Storage is automatically created when you add your first annotation +- Storage is automatically created when you save your first annotation or custom tag - The `.annotative/` folder appears in your workspace root -- No configuration required +- `Annotative: Storage Info` shows whether storage has already been created for the current workspace ## Troubleshooting ### I don't see my old annotations -Old annotations from v1.5.0 should still appear. If they don't: +Legacy annotations from v1.5.0 are not imported automatically. If they are missing: 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 +3. Check whether the old data still lives in VS Code global state from v1.5.0 +4. Recreate or manually migrate the annotations into `.annotative/annotations.json` ### Tags show as "undefined" or old tag names @@ -203,7 +217,8 @@ 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 +3. Try `Annotative: Initialize Storage` to create the folder explicitly +4. Check the Output panel (View > Output > Annotative) for errors ## FAQ @@ -213,11 +228,11 @@ A: Yes! Create custom tags with the same names (bug, todo, review, etc.). The fu **Q: Do I need to manually initialize storage?** -A: No. Storage is automatically created when you add your first annotation in v2.0.0. +A: No. Storage is created on first save, but `Annotative: Initialize Storage` is still available if you want to create the folder before that. **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. +A: They are not imported automatically by the current implementation. Keep a copy of the old data and migrate it deliberately into project storage. **Q: Can I share annotations without version control?** @@ -253,7 +268,8 @@ The v2.0.0 upgrade brings: The migration requires: - Creating custom tags to replace presets -- Automatic storage initialization (no action needed) +- Creating project storage on first save or with `Annotative: Initialize Storage` +- Deliberately migrating any legacy global-storage data - Committing `.annotative/` to share with your team Welcome to Annotative v2.0.0! diff --git a/README.md b/README.md index 6e1fae2..7afd929 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -![Annotative Logo](media/annotative-logo/128px/annotative-logo.png) - # Annotative +![Annotative Logo](media/annotative-logo/128px/annotative-logo.png) + 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. ## Overview @@ -21,7 +21,7 @@ Primary use cases: Annotative v2.0.0 introduces breaking changes to the tag system and storage. See [MIGRATION.md](MIGRATION.md) for detailed upgrade instructions, including: - How to recreate preset tags as custom tags -- Automatic project storage initialization +- What happens to legacy global-storage data - Updating existing annotations with new tags - Sharing annotations with your team via version control @@ -38,50 +38,51 @@ Annotative v2.0.0 introduces breaking changes to the tag system and storage. See ### Core Functionality -**Annotation Management** +#### Annotation Management - Add annotations to selected code with keyboard shortcuts or context menu -- Edit annotation comments directly in the sidebar +- Edit annotation comments directly in the sidebar webview - Mark annotations as resolved when issues are addressed - Delete individual or bulk annotations - Undo the most recent annotation -**Organization** +#### Organization -- Group annotations by file, tag, or resolution status -- Filter by status (all, open, resolved) +- Group annotations by file, folder, tag, or resolution status +- Filter by status (all, unresolved, resolved) - Filter by custom tags -- Search across all annotations in the workspace - Navigate between annotations with keyboard shortcuts +- Keep sidebar filter state while the webview stays open in the current window session -**Custom Tags** +#### Custom Tags -- Create project-specific tags with custom names and colors +- Create project-specific tags with custom names, categories, priorities, and colors - Edit existing tags to update properties - Delete unused tags - No preset tags - all tags are user-defined -**Visual Highlighting** +#### Visual Highlighting - Inline code highlighting in the editor - Eight color options for visual preference - Decorations update automatically when switching files -**Export and Sharing** +#### 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 +- Export for Copilot, ChatGPT, Claude, or a generic AI format +- Save Copilot-oriented exports to `.copilot/annotations` +- Run batch AI review for up to 10 unresolved annotations at a time ### GitHub Copilot Integration -Use the `@annotative` chat participant to interact with your annotations: +When GitHub Copilot Chat is installed and `annotative.copilot.enabled` is on, Annotative registers an `@annotative` chat participant and adds export helpers. -- Review all annotations in the active file -- Ask questions about specific annotations -- Request suggestions for flagged issues -- Copy annotations as context for Copilot +- Review unresolved annotations across the workspace or the active file +- Explain an annotation, suggest fixes, or review the active file with annotation context +- Use template-driven annotation creation and send the new annotation to Copilot immediately +- Export annotation sets to clipboard, a document, or `.copilot/annotations` Chat participant commands: @@ -92,19 +93,15 @@ Chat participant commands: ### Storage Options -**Project Storage** +Annotative currently uses project storage only. -- Initialize `.annotative/` folder in your workspace -- Annotations stored as `annotations.json` in the project +- The `.annotative/` folder is created on first annotation or custom-tag save, or manually with `Annotative: Initialize Storage` +- Annotations are stored in `.annotative/annotations.json` +- Custom tags are stored in `.annotative/customTags.json` +- A `.annotative/README.md` file is created with version-control guidance - 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: @@ -114,7 +111,7 @@ Install from the VS Code Marketplace: 3. Search for "Annotative" 4. Click Install -No additional configuration required - the extension works immediately after installation. +No additional configuration is required for annotation features. Copilot-specific flows require GitHub Copilot Chat. ## Requirements @@ -151,7 +148,8 @@ No additional configuration required - the extension works immediately after ins - 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 +- Group annotations by file, folder, tag, or status using the dropdown +- Toolbar actions refresh and repopulate the sidebar when the webview becomes visible again **Editing:** @@ -171,11 +169,11 @@ No additional configuration required - the extension works immediately after ins - "Delete Resolved" removes all resolved annotations - "Delete All" clears all annotations in the workspace -### Filtering and Search +### Filtering **Filter by Status:** -- Show all, only open, or only resolved annotations +- Show all, only unresolved, or only resolved annotations - Access via the filter icon in the sidebar toolbar **Filter by Tag:** @@ -183,32 +181,24 @@ No additional configuration required - the extension works immediately after ins - 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 | +| 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+Alt+E (Cmd+Alt+E) | Export annotations by intent | -### Custom Tags +### Tag Management **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 +4. Select category, priority, and color **Editing Tags:** @@ -230,12 +220,14 @@ No additional configuration required - the extension works immediately after ins ### Project Storage +Storage is created automatically on first save, but you can also initialize it explicitly. + **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` +4. The folder contains `README.md`, and future saves write `annotations.json` and `customTags.json` **Share with Team:** @@ -246,7 +238,7 @@ No additional configuration required - the extension works immediately after ins **Check Storage Location:** - Run "Annotative: Storage Info" to see current storage path -- Shows whether using project or global storage +- Shows whether project storage has already been created for the current workspace ### Exporting Annotations @@ -265,7 +257,8 @@ No additional configuration required - the extension works immediately after ins **For AI Tools:** - Click "Export for AI" for optimized formatting -- Includes context lines and code snippets +- Choose Copilot, ChatGPT, Claude, or Generic format +- Includes configured context lines and optional import lines - Paste directly into Copilot, ChatGPT, or Claude **By Intent:** @@ -273,6 +266,13 @@ No additional configuration required - the extension works immediately after ins - Press Ctrl+Alt+E (Cmd+Alt+E on macOS) - Choose export intent (review, documentation, issue tracking) - Format optimized for the selected intent +- Optionally save the prepared export under `.copilot/annotations` + +**Batch AI Review:** + +- Run "Annotative: Batch AI Review" from the sidebar toolbar +- Annotative prepares a report for up to 10 unresolved annotations +- Copy the report, view it in a document, open Copilot Chat, or save it to `.copilot/annotations` ## Commands @@ -296,9 +296,8 @@ Access all commands via the Command Palette (Ctrl+Shift+P / Cmd+Shift+P): **Filter Commands:** -- `Annotative: Filter by Status` - Filter all, open, or resolved +- `Annotative: Filter by Status` - Filter all, unresolved, or resolved - `Annotative: Filter by Tag` - Filter by custom tag -- `Annotative: Search` - Search annotations - `Annotative: Clear Filters` - Reset all filters - `Annotative: Refresh` - Reload annotations @@ -319,20 +318,15 @@ Access all commands via the Command Palette (Ctrl+Shift+P / Cmd+Shift+P): - `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 +- `Annotative: Export by Intent` - Prepare Copilot-oriented output for review, bugs, optimization, or documentation +- `Annotative: Export for AI` - Export for Copilot, ChatGPT, Claude, or a generic AI format +- `Annotative: Batch AI Review` - Prepare a multi-annotation AI review report **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 - ## Configuration Configure via VS Code settings (File > Preferences > Settings): @@ -341,15 +335,12 @@ Configure via VS Code settings (File > Preferences > Settings): - `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 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 0115a23..2c49f60 100644 --- a/package.json +++ b/package.json @@ -410,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": "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}`); From af68776f5705b1f079e8bc6d683c65401448f051 Mon Sep 17 00:00:00 2001 From: Bryan Shea Date: Tue, 24 Mar 2026 16:05:04 -0400 Subject: [PATCH 8/8] release: prepare v3.0.0 core stability and architecture refresh ## Summary Prepares Annotative `v3.0.0` for release by landing the core stability and architecture refresh work and aligning the repository docs and release metadata with the current product state. ## Included in v3.0.0 - hardens project-scoped storage and schema handling - adds anchored annotation reattachment for source edits and movement - cleans up export flows and multi-root workspace handling - expands regression coverage across storage, CRUD, exports, manager behavior, anchoring, and sidebar flows - keeps the project-based `.annotative/` storage model introduced in `v2` - aligns README, migration guidance, contributing docs, testing docs, and release docs with the actual `v3` behavior - updates release guidance to match the manual `main`-based Marketplace publish workflow ## Upgrade Notes - upgrading from `v2.x` to `v3.0.0` does not require a manual storage migration - upgrading from pre-`v2` versions still requires manual recreation or migration of legacy global-state annotations - release metadata is prepared in git for `v3.0.0` ## Validation - `npm run compile` - `npm run lint` - `npm test` - `node scripts/check-extension-compliance.js --verbose` ## Known Non-Blocking Warning - compliance still reports existing console statements in `media/sidebar-webview.js` --- CHANGELOG.md | 39 +++- CONTRIBUTING.md | 281 +++++++++----------------- MIGRATION.md | 296 +++++---------------------- README.md | 430 ++++++++-------------------------------- src/commands/sidebar.ts | 10 +- 5 files changed, 272 insertions(+), 784 deletions(-) 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 39ded95..588e95d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,235 +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 20.x or 22.x -- 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 - -### Building +## Core Commands -- `npm run compile` - Compile TypeScript once -- `npm run watch` - Compile in watch mode for development -- `npm run package` - Build production bundle with webpack -- `npm run quality` - Run compile, lint, tests, and compliance checks -- `npm run release:check` - Run the full local release verification flow +- `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 -### Testing +## Testing Expectations -- `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 quality -``` - -## Code Standards - -### TypeScript - -- Use strict TypeScript mode -- Define interfaces for all data structures -- Avoid `any` types when possible -- Use async/await for asynchronous operations - -### Code Style - -- 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: - -```text -feat: add new feature -fix: resolve bug -docs: update documentation -refactor: restructure code -test: add or update tests -chore: maintenance tasks +npm run release:check ``` -Examples: - -- `feat: add batch annotation export` -- `fix: resolve sidebar rendering issue` -- `docs: update README with new commands` - -### Architecture Guidelines - -- **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 - -Keep concerns separated: - -- Commands handle user input and orchestration -- Managers handle data and business rules -- UI components handle presentation - -## Pull Request Process - -1. Fork the repository -2. Create a feature branch: +Notes: - ```bash - git checkout -b feature/your-feature-name - ``` +- `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 -3. Make your changes: - - Write code following the standards above - - Add or update tests if applicable - - Update documentation if needed +## Code Guidelines -4. Test thoroughly: - - Run all quality checks - - Test in Extension Development Host - - Verify no regressions +- 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 -5. Commit your changes: +## Pull Request Expectations - ```bash - git add . - git commit -m "feat: your feature description" - ``` +Each pull request should: -6. Push to your fork: +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. - ```bash - git push origin feature/your-feature-name - ``` +Recommended PR checklist: -7. Open a pull request: - - Provide clear description of changes - - Reference any related issues - - Include screenshots for UI changes +- [ ] `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 -## Bug Reports +## Commit Messages -When filing bug reports, include: +Use conventional commits where practical: -- **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: - -- [README.md](README.md) - User-facing documentation -- [CHANGELOG.md](CHANGELOG.md) - Version history -- [docs/](docs/) - Technical documentation +```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 +``` -## Release Process +## Release Ownership -Releases are managed by maintainers. The process includes: +Maintainers handle releases. -1. Prepare and commit the release version and changelog on `main` -2. Run `npm run release:check` locally if you are preparing the release commit -3. Trigger the manual GitHub Actions release workflow from `main` -4. Let the workflow validate, package, publish, and tag that already-versioned commit +Current release flow: -Contributors do not need to manage versions or releases. +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. -Do not use local shortcuts that stage everything, bypass hooks, or push directly to `main` as part of normal contribution flow. +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 b106608..36e37c2 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,275 +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 -- Annotative now writes annotations and custom tags into a `.annotative/` folder in your workspace root -- The folder is created on first annotation or custom-tag save, or manually with `Annotative: Initialize Storage` -- Global-storage migration is not automatic in the current implementation -- 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 - -**Create the project storage folder:** - -You can initialize storage in either of these ways: - -1. Run `Annotative: Initialize Storage` -2. Or create your first annotation or custom tag and let Annotative create `.annotative/` on save - -The folder will contain: - -- `README.md` - Storage and version-control guidance -- `annotations.json` - Saved annotations -- `customTags.json` - Saved custom tags - -### Step 4: Handle legacy v1.5.0 data - -If you have existing annotations in global storage from v1.5.0: - -1. Do not assume they will appear automatically after upgrading -2. The current extension code does not import legacy global-state annotations for you -3. Recreate the annotations in the project workspace, or manually move/export the data before relying on v2.0.0 project storage - -**Note:** If migrated or shared annotations still reference old preset tag names, edit them and map them onto your new custom tags. - -### Step 5: 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 -- Review annotations file-by-file and replace old preset tag references with project-specific tags - -### Step 6: 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: - -- `README.md` - Storage guidance created by Annotative -- `annotations.json` - All annotations -- `customTags.json` - Your custom tag definitions - -### Optional: Exclude Annotations from Version Control - -If you want to keep annotations private: - -1. Add to your project's `.gitignore`: - - ```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. - -### Manual Storage Initialization Is Still Available - -You do not need to run `Annotative: Initialize Storage`, but the command still exists if you want the folder created before your first save. - -- Storage is automatically created when you save your first annotation or custom tag -- The `.annotative/` folder appears in your workspace root -- `Annotative: Storage Info` shows whether storage has already been created for the current workspace +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 - -Legacy annotations from v1.5.0 are not imported automatically. If they are missing: - -1. Run `Annotative: Storage Info` to see your storage location -2. Check if a `.annotative/` folder exists in your workspace root -3. Check whether the old data still lives in VS Code global state from v1.5.0 -4. Recreate or manually migrate the annotations into `.annotative/annotations.json` - -### 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. Try `Annotative: Initialize Storage` to create the folder explicitly -4. 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 created on first save, but `Annotative: Initialize Storage` is still available if you want to create the folder before that. - -**Q: Will my old annotations be lost?** - -A: They are not imported automatically by the current implementation. Keep a copy of the old data and migrate it deliberately into project storage. - -**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 -- Creating project storage on first save or with `Annotative: Initialize Storage` -- Deliberately migrating any legacy global-storage data -- 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 7afd929..be75283 100644 --- a/README.md +++ b/README.md @@ -2,394 +2,136 @@ ![Annotative Logo](media/annotative-logo/128px/annotative-logo.png) -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 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. -## Overview +## Version -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. +This branch prepares Annotative `v3.0.0`. -Primary use cases: +`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. -- 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 +Upgrade notes are in [MIGRATION.md](MIGRATION.md). -## Upgrading from v1.5.0? +## What It Does -Annotative v2.0.0 introduces breaking changes to the tag system and storage. See [MIGRATION.md](MIGRATION.md) for detailed upgrade instructions, including: - -- How to recreate preset tags as custom tags -- What happens to legacy global-storage data -- 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 webview -- Mark annotations as resolved when issues are addressed -- Delete individual or bulk annotations -- Undo the most recent annotation - -#### Organization - -- Group annotations by file, folder, tag, or resolution status -- Filter by status (all, unresolved, resolved) -- Filter by custom tags -- Navigate between annotations with keyboard shortcuts -- Keep sidebar filter state while the webview stays open in the current window session - -#### Custom Tags - -- Create project-specific tags with custom names, categories, priorities, 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 -- Export for Copilot, ChatGPT, Claude, or a generic AI format -- Save Copilot-oriented exports to `.copilot/annotations` -- Run batch AI review for up to 10 unresolved annotations at a time - -### GitHub Copilot Integration - -When GitHub Copilot Chat is installed and `annotative.copilot.enabled` is on, Annotative registers an `@annotative` chat participant and adds export helpers. - -- Review unresolved annotations across the workspace or the active file -- Explain an annotation, suggest fixes, or review the active file with annotation context -- Use template-driven annotation creation and send the new annotation to Copilot immediately -- Export annotation sets to clipboard, a document, or `.copilot/annotations` - -Chat participant commands: - -- `/issues` - Show open annotations -- `/explain` - Get detailed explanations -- `/fix` - Request fix suggestions -- `/review` - Review with full context - -### Storage Options - -Annotative currently uses project storage only. - -- The `.annotative/` folder is created on first annotation or custom-tag save, or manually with `Annotative: Initialize Storage` -- Annotations are stored in `.annotative/annotations.json` -- Custom tags are stored in `.annotative/customTags.json` -- A `.annotative/README.md` file is created with version-control guidance -- Share annotations with your team via version control -- Portable across different machines - -## 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 is required for annotation features. Copilot-specific flows require GitHub Copilot Chat. - -## 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, folder, tag, or status using the dropdown -- Toolbar actions refresh and repopulate the sidebar when the webview becomes visible again - -**Editing:** - -- Click the edit button in the sidebar -- Update the comment text in the input box -- Changes save automatically - -**Resolution Tracking:** +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. -- Click the toggle status button to mark as resolved -- Resolved annotations remain visible but are flagged -- Use "Delete Resolved" to clean up completed items +## Storage Model -**Bulk Operations:** +Annotative stores project data in `.annotative/` at the workspace root. -- "Resolve All" marks all annotations as resolved -- "Delete Resolved" removes all resolved annotations -- "Delete All" clears all annotations in the workspace +- `annotations.json` stores annotations +- `customTags.json` stores custom tag definitions +- `README.md` explains how to include or ignore the folder in version control -### Filtering +Storage is created automatically on first save, or explicitly with `Annotative: Initialize Storage`. -**Filter by Status:** +## Key Capabilities -- Show all, only unresolved, or only resolved annotations -- Access via the filter icon in the sidebar toolbar +### Annotation workflow -**Filter by Tag:** - -- Focus on annotations with specific tags -- Only available if custom tags have been created - -### 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+Alt+E (Cmd+Alt+E) | Export annotations by intent | - -### Tag Management - -**Creating Tags:** - -1. Open Command Palette (Ctrl+Shift+P / Cmd+Shift+P) -2. Run "Annotative: Create Tag" -3. Enter tag name -4. Select category, priority, and color - -**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 - -Storage is created automatically on first save, but you can also initialize it explicitly. - -**Initialize Project Storage:** - -1. Open Command Palette -2. Run "Annotative: Initialize Storage" -3. A `.annotative/` folder is created in the workspace root -4. The folder contains `README.md`, and future saves write `annotations.json` and `customTags.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 - -**Check Storage Location:** - -- Run "Annotative: Storage Info" to see current storage path -- Shows whether project storage has already been created for the current workspace +- 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 -### Exporting Annotations +### Sidebar workflow -**To Clipboard:** +- 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` -- Click "Export to Clipboard" in the sidebar toolbar -- Markdown content copied to clipboard -- Paste into documents, chats, or issues +### Tag workflow -**To Document:** +- Create user-defined tags with category, priority, and color +- Edit or delete existing tags +- Share tag definitions through `.annotative/customTags.json` -- Click "Export to Document" in the sidebar toolbar -- Opens a new untitled document with Markdown content -- Edit and save as needed +### Export workflow -**For AI Tools:** +- 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 for AI" for optimized formatting -- Choose Copilot, ChatGPT, Claude, or Generic format -- Includes configured context lines and optional import lines -- Paste directly into Copilot, ChatGPT, or Claude +## Copilot Integration -**By Intent:** +When GitHub Copilot Chat is installed and `annotative.copilot.enabled` is enabled, Annotative registers an `@annotative` chat participant. -- Press Ctrl+Alt+E (Cmd+Alt+E on macOS) -- Choose export intent (review, documentation, issue tracking) -- Format optimized for the selected intent -- Optionally save the prepared export under `.copilot/annotations` +Supported commands: -**Batch AI Review:** +- `/issues` +- `/explain` +- `/fix` +- `/review` -- Run "Annotative: Batch AI Review" from the sidebar toolbar -- Annotative prepares a report for up to 10 unresolved annotations -- Copy the report, view it in a document, open Copilot Chat, or save it to `.copilot/annotations` +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:** +Key command groups: -- `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 +- 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` -**Navigation Commands:** +## Keyboard Shortcuts -- `Annotative: Next` - Go to next annotation -- `Annotative: Previous` - Go to previous annotation -- `Annotative: Go to Location` - Navigate to annotation source - -**Filter Commands:** - -- `Annotative: Filter by Status` - Filter all, unresolved, or resolved -- `Annotative: Filter by Tag` - Filter by custom tag -- `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` - Prepare Copilot-oriented output for review, bugs, optimization, or documentation -- `Annotative: Export for AI` - Export for Copilot, ChatGPT, Claude, or a generic AI format -- `Annotative: Batch AI Review` - Prepare a multi-annotation AI review report - -**Storage Commands:** - -- `Annotative: Initialize Storage` - Create project storage -- `Annotative: Storage Info` - Show storage location +| 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): - -**Export Settings:** +Annotative currently exposes these settings: -- `annotative.export.contextLines` - Number of context lines in exports (default: 5) -- `annotative.export.includeImports` - Include imports in context (default: true) +- `annotative.export.contextLines` +- `annotative.export.includeImports` +- `annotative.copilot.enabled` +- `annotative.copilot.autoAttachContext` +- `annotative.copilot.preferredFormat` +- `annotative.copilot.autoOpenChat` -**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.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/src/commands/sidebar.ts b/src/commands/sidebar.ts index 5eb4c44..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 @@ -49,7 +57,7 @@ export function registerSidebarCommands( sidebarWebview.refreshAnnotations(); } catch (error) { - vscode.window.showErrorMessage(`Failed to initialize: ${error}`); + vscode.window.showErrorMessage(getStorageInitializationErrorMessage(error)); } } );