diff --git a/package.json b/package.json index fe6f8ef..2e2bfad 100644 --- a/package.json +++ b/package.json @@ -196,6 +196,24 @@ "title": "View History", "icon": "$(history)", "category": "Code Notes" + }, + { + "command": "codeContextNotes.searchNotes", + "title": "Search Notes", + "icon": "$(search)", + "category": "Code Notes" + }, + { + "command": "codeContextNotes.filterByTags", + "title": "Filter by Tags", + "icon": "$(tag)", + "category": "Code Notes" + }, + { + "command": "codeContextNotes.clearTagFilters", + "title": "Clear Tag Filters", + "icon": "$(clear-all)", + "category": "Code Notes" } ], "keybindings": [ @@ -223,6 +241,12 @@ "mac": "cmd+alt+r", "when": "editorTextFocus" }, + { + "command": "codeContextNotes.searchNotes", + "key": "ctrl+alt+shift+f", + "mac": "cmd+alt+shift+f", + "when": "editorTextFocus && !searchViewletFocus && !replaceInputBoxFocus" + }, { "command": "codeContextNotes.insertBold", "key": "ctrl+b", @@ -268,10 +292,20 @@ "when": "view == codeContextNotes.sidebarView", "group": "navigation@1" }, + { + "command": "codeContextNotes.searchNotes", + "when": "view == codeContextNotes.sidebarView", + "group": "navigation@2" + }, { "command": "codeContextNotes.refreshSidebar", "when": "view == codeContextNotes.sidebarView", "group": "navigation@3" + }, + { + "command": "codeContextNotes.filterByTags", + "when": "view == codeContextNotes.sidebarView", + "group": "navigation@4" } ], "view/item/context": [ diff --git a/src/codeLensProvider.ts b/src/codeLensProvider.ts index ac955d5..32af9a8 100644 --- a/src/codeLensProvider.ts +++ b/src/codeLensProvider.ts @@ -7,6 +7,12 @@ import * as vscode from 'vscode'; import { Note } from './types.js'; import { NoteManager } from './noteManager.js'; +// Preview length constants +const MIN_PREVIEW_LENGTH = 10; // Minimum characters for note preview +const MAX_PREVIEW_LENGTH_SINGLE_NOTE = 50; // Max preview length for single note +const MAX_PREVIEW_LENGTH_MULTI_NOTE = 35; // Max preview length for multiple notes +const MAX_TAGS_TO_DISPLAY = 2; // Max tags to show before truncating + /** * CodeLensProvider displays indicators above lines with notes */ @@ -154,15 +160,25 @@ export class CodeNotesLensProvider implements vscode.CodeLensProvider { private formatCodeLensTitle(notes: Note[]): string { if (notes.length === 1) { const note = notes[0]; + + // Format tags for display + let tagsDisplay = ''; + if (note.tags && note.tags.length > 0) { + tagsDisplay = note.tags.map(tag => `[${tag}]`).join(' ') + ' '; + } + // Strip markdown formatting and get first line const plainText = this.stripMarkdown(note.content); const firstLine = plainText.split('\n')[0]; - const preview = firstLine.length > 50 - ? firstLine.substring(0, 47) + '...' + + // Calculate available space for preview (account for tags) with minimum guard + const maxPreviewLength = Math.max(MIN_PREVIEW_LENGTH, MAX_PREVIEW_LENGTH_SINGLE_NOTE - tagsDisplay.length); + const preview = firstLine.length > maxPreviewLength + ? firstLine.substring(0, maxPreviewLength - 3) + '...' : firstLine; - // Format: "๐Ÿ“ Note: preview text (by author)" - return `๐Ÿ“ Note: ${preview} (${note.author})`; + // Format: "๐Ÿ“ [TODO] [bug] Note: preview text (by author)" + return `๐Ÿ“ ${tagsDisplay}Note: ${preview} (${note.author})`; } else { // Multiple notes - show count and authors const uniqueAuthors = [...new Set(notes.map(n => n.author))]; @@ -170,15 +186,36 @@ export class CodeNotesLensProvider implements vscode.CodeLensProvider { ? `${uniqueAuthors.slice(0, 2).join(', ')} +${uniqueAuthors.length - 2} more` : uniqueAuthors.join(', '); + // Collect all unique tags from all notes + const allTags = new Set(); + notes.forEach(note => { + if (note.tags) { + note.tags.forEach(tag => allTags.add(tag)); + } + }); + + // Format tags for display (limit to MAX_TAGS_TO_DISPLAY if many) + let tagsDisplay = ''; + if (allTags.size > 0) { + const tagArray = Array.from(allTags); + const displayTags = tagArray.slice(0, MAX_TAGS_TO_DISPLAY); + tagsDisplay = displayTags.map(tag => `[${tag}]`).join(' '); + if (tagArray.length > MAX_TAGS_TO_DISPLAY) { + tagsDisplay += ` +${tagArray.length - MAX_TAGS_TO_DISPLAY}`; + } + tagsDisplay += ' '; + } + // Get preview from first note const plainText = this.stripMarkdown(notes[0].content); const firstLine = plainText.split('\n')[0]; - const preview = firstLine.length > 35 - ? firstLine.substring(0, 32) + '...' + const maxPreviewLength = Math.max(MIN_PREVIEW_LENGTH, MAX_PREVIEW_LENGTH_MULTI_NOTE - tagsDisplay.length); + const preview = firstLine.length > maxPreviewLength + ? firstLine.substring(0, maxPreviewLength - 3) + '...' : firstLine; - // Format: "๐Ÿ“ Notes (3): preview... (by author1, author2)" - return `๐Ÿ“ Notes (${notes.length}): ${preview} (${authorsDisplay})`; + // Format: "๐Ÿ“ [TODO] [bug] Notes (3): preview... (by author1, author2)" + return `๐Ÿ“ ${tagsDisplay}Notes (${notes.length}): ${preview} (${authorsDisplay})`; } } diff --git a/src/commentController.ts b/src/commentController.ts index 390c83d..879597e 100644 --- a/src/commentController.ts +++ b/src/commentController.ts @@ -525,12 +525,35 @@ export class CommentController { end: thread.range.end.line, }; + // Prompt for tags with robust fallback + let tags: string[] | undefined; + try { + const { TagInputUI } = await import('./tagInputUI.js'); + const allNotes = await this.noteManager.getAllNotes(); + tags = await TagInputUI.showTagInput(undefined, allNotes); + } catch (e) { + // Non-interactive failure: proceed with empty tags + console.error('Failed to load tag input UI:', e); + tags = []; + } + + // If user cancelled tag input (explicit dismissal), cancel note creation + if (tags === undefined) { + thread.dispose(); + if (tempId) { + this.commentThreads.delete(tempId); + } + this.currentlyCreatingThreadId = null; + return; + } + // Create the actual note const note = await this.noteManager.createNote( { filePath: document.uri.fsPath, lineRange, content, + tags, }, document ); @@ -554,7 +577,8 @@ export class CommentController { async handleCreateNote( document: vscode.TextDocument, range: vscode.Range, - content: string + content: string, + tags?: string[] ): Promise { const lineRange: LineRange = { start: range.start.line, @@ -566,6 +590,7 @@ export class CommentController { filePath: document.uri.fsPath, lineRange, content, + tags, }, document ); diff --git a/src/extension.ts b/src/extension.ts index f44ec44..197127b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,16 +11,19 @@ import { CommentController } from './commentController.js'; import { CodeNotesLensProvider } from './codeLensProvider.js'; import { NotesSidebarProvider } from './notesSidebarProvider.js'; import { SearchManager } from './searchManager.js'; +import { SearchUI } from './searchUI.js'; let noteManager: NoteManager; -let searchManager: SearchManager; let commentController: CommentController; let codeLensProvider: CodeNotesLensProvider; let sidebarProvider: NotesSidebarProvider; +let searchManager: SearchManager; // Debounce timers for performance optimization const documentChangeTimers: Map = new Map(); const DEBOUNCE_DELAY = 500; // ms +const LARGE_INDEX_THRESHOLD = 100; // Show completion message for large indexes +const INDEX_BUILD_DELAY = 1000; // Delay to not block activation (ms) /** * Extension activation @@ -80,15 +83,9 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize search manager searchManager = new SearchManager(context); - // Connect search manager to note manager + // Link search manager to note manager (avoids circular dependency) noteManager.setSearchManager(searchManager); - // Build initial search index with all existing notes - console.log('Code Context Notes: Building initial search index...'); - const allNotes = await noteManager.getAllNotes(); - await searchManager.buildIndex(allNotes); - console.log(`Code Context Notes: Search index built with ${allNotes.length} notes`); - // Initialize comment controller commentController = new CommentController(noteManager, context); @@ -112,6 +109,37 @@ export async function activate(context: vscode.ExtensionContext) { }); context.subscriptions.push(treeView); + // Build search index in background with progress notification + console.log('Code Context Notes: Building search index...'); + setTimeout(async () => { + try { + // Show progress for large workspaces + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: "Code Context Notes", + cancellable: false + }, async (progress) => { + progress.report({ message: "Building search index..." }); + + const allNotes = await noteManager.getAllNotes(); + await searchManager.buildIndex(allNotes); + + progress.report({ message: `Search index ready (${allNotes.length} notes)` }); + console.log(`Code Context Notes: Search index built with ${allNotes.length} notes`); + + // Show completion message for large indexes + if (allNotes.length > LARGE_INDEX_THRESHOLD) { + setTimeout(() => { + vscode.window.showInformationMessage(`Code Context Notes: Search index ready with ${allNotes.length} notes`); + }, 500); + } + }); + } catch (error) { + console.error('Code Context Notes: Failed to build search index:', error); + vscode.window.showErrorMessage(`Code Context Notes: Failed to build search index: ${error}`); + } + }, INDEX_BUILD_DELAY); + // Set up event listeners setupEventListeners(context); @@ -832,6 +860,25 @@ function registerAllCommands(context: vscode.ExtensionContext) { } ); + // Search Notes + const searchNotesCommand = vscode.commands.registerCommand( + 'codeContextNotes.searchNotes', + async () => { + if (!noteManager || !searchManager) { + vscode.window.showErrorMessage('Code Context Notes requires a workspace folder to be opened.'); + return; + } + + try { + const searchUI = new SearchUI(searchManager, noteManager); + await searchUI.show(); + } catch (error) { + console.error('Search failed:', error); + vscode.window.showErrorMessage(`Search failed: ${error}`); + } + } + ); + // Collapse All in Sidebar const collapseAllCommand = vscode.commands.registerCommand( 'codeContextNotes.collapseAll', @@ -932,6 +979,42 @@ function registerAllCommands(context: vscode.ExtensionContext) { } ); + // Filter Notes by Tags + const filterByTagsCommand = vscode.commands.registerCommand( + 'codeContextNotes.filterByTags', + async () => { + if (!noteManager || !sidebarProvider) { + vscode.window.showErrorMessage('Code Context Notes requires a workspace folder to be opened.'); + return; + } + + try { + const { TagInputUI } = await import('./tagInputUI.js'); + const allNotes = await noteManager.getAllNotes(); + const selectedTags = await TagInputUI.showTagFilter(allNotes); + + if (selectedTags && selectedTags.length > 0) { + sidebarProvider.setTagFilters(selectedTags, 'any'); + vscode.window.showInformationMessage(`Filtering by tags: ${selectedTags.join(', ')}`); + } + } catch (error) { + vscode.window.showErrorMessage(`Failed to filter by tags: ${error}`); + } + } + ); + + // Clear Tag Filters + const clearTagFiltersCommand = vscode.commands.registerCommand( + 'codeContextNotes.clearTagFilters', + () => { + if (!sidebarProvider) { + return; + } + sidebarProvider.clearTagFilters(); + vscode.window.showInformationMessage('Tag filters cleared'); + } + ); + // Register all commands context.subscriptions.push( addNoteCommand, @@ -959,11 +1042,14 @@ function registerAllCommands(context: vscode.ExtensionContext) { addNoteToLineCommand, openNoteFromSidebarCommand, refreshSidebarCommand, + searchNotesCommand, collapseAllCommand, editNoteFromSidebarCommand, deleteNoteFromSidebarCommand, viewNoteHistoryFromSidebarCommand, - openFileFromSidebarCommand + openFileFromSidebarCommand, + filterByTagsCommand, + clearTagFiltersCommand ); } diff --git a/src/noteManager.ts b/src/noteManager.ts index 0c8bc8f..b92b5c3 100644 --- a/src/noteManager.ts +++ b/src/noteManager.ts @@ -92,7 +92,8 @@ export class NoteManager extends EventEmitter { action: 'created' } ], - isDeleted: false + isDeleted: false, + tags: params.tags || [] }; // Save to storage @@ -140,6 +141,11 @@ export class NoteManager extends EventEmitter { note.author = author; note.updatedAt = now; + // Update tags if provided + if (params.tags !== undefined) { + note.tags = params.tags; + } + // Add history entry note.history.push({ content: params.content.trim(), diff --git a/src/noteTreeItem.ts b/src/noteTreeItem.ts index 19c8e57..aa00750 100644 --- a/src/noteTreeItem.ts +++ b/src/noteTreeItem.ts @@ -96,8 +96,7 @@ export class NoteTreeItem extends BaseTreeItem { */ private createTooltip(): vscode.MarkdownString { const tooltip = new vscode.MarkdownString(); - tooltip.isTrusted = true; - tooltip.supportHtml = true; + tooltip.isTrusted = false; const lineRange = `Lines ${this.note.lineRange.start + 1}-${this.note.lineRange.end + 1}`; const created = new Date(this.note.createdAt).toLocaleString(); @@ -108,7 +107,8 @@ export class NoteTreeItem extends BaseTreeItem { tooltip.appendMarkdown(`**Created:** ${created}\n\n`); tooltip.appendMarkdown(`**Updated:** ${updated}\n\n`); tooltip.appendMarkdown(`---\n\n`); - tooltip.appendMarkdown(this.note.content); + // Use appendText for user content to prevent injection + tooltip.appendText(this.note.content); return tooltip; } diff --git a/src/notesSidebarProvider.ts b/src/notesSidebarProvider.ts index f8ddac4..b905a3c 100644 --- a/src/notesSidebarProvider.ts +++ b/src/notesSidebarProvider.ts @@ -3,217 +3,318 @@ * Implements VSCode TreeDataProvider for displaying notes in sidebar */ -import * as vscode from 'vscode'; -import { NoteManager } from './noteManager.js'; -import { Note } from './types.js'; -import { RootTreeItem, FileTreeItem, NoteTreeItem, BaseTreeItem } from './noteTreeItem.js'; +import * as vscode from "vscode"; +import { NoteManager } from "./noteManager.js"; +import { + BaseTreeItem, + FileTreeItem, + NoteTreeItem, + RootTreeItem, +} from "./noteTreeItem.js"; +import { Note } from "./types.js"; +import { TagManager } from "./tagManager.js"; /** * Notes Sidebar Provider * Displays all workspace notes in a tree structure organized by file */ -export class NotesSidebarProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData: vscode.EventEmitter = - new vscode.EventEmitter(); - readonly onDidChangeTreeData: vscode.Event = - this._onDidChangeTreeData.event; - - private debounceTimer: NodeJS.Timeout | null = null; - private readonly DEBOUNCE_DELAY = 300; // ms - private disposables: vscode.Disposable[] = []; - - constructor( - private readonly noteManager: NoteManager, - private readonly workspaceRoot: string, - private readonly context: vscode.ExtensionContext - ) { - // Listen for note changes from NoteManager - this.setupEventListeners(); - - // Register dispose method so VS Code can clean up when deactivating - this.context.subscriptions.push(this); - } - - /** - * Set up event listeners for real-time updates - */ - private setupEventListeners(): void { - // Listen for note changes (create/update/delete) - const noteChangedHandler = () => { - this.refresh(); - }; - this.noteManager.on('noteChanged', noteChangedHandler); - this.disposables.push(new vscode.Disposable(() => { - this.noteManager.removeListener('noteChanged', noteChangedHandler); - })); - - // Listen for file changes (external modifications) - const noteFileChangedHandler = () => { - this.refresh(); - }; - this.noteManager.on('noteFileChanged', noteFileChangedHandler); - this.disposables.push(new vscode.Disposable(() => { - this.noteManager.removeListener('noteFileChanged', noteFileChangedHandler); - })); - } - - /** - * Refresh the tree view (debounced for performance) - */ - refresh(): void { - // Clear existing timer - if (this.debounceTimer) { - clearTimeout(this.debounceTimer); - } - - // Set new debounced timer - this.debounceTimer = setTimeout(() => { - this._onDidChangeTreeData.fire(); - this.debounceTimer = null; - }, this.DEBOUNCE_DELAY); - } - - /** - * Get tree item for a node (required by TreeDataProvider) - */ - getTreeItem(element: BaseTreeItem): vscode.TreeItem { - return element; - } - - /** - * Get children for a tree node (required by TreeDataProvider) - * Implements lazy loading for performance - */ - async getChildren(element?: BaseTreeItem): Promise { - // Root level: return RootTreeItem or empty state - if (!element) { - const noteCount = await this.noteManager.getNoteCount(); - - // Empty state - no notes - if (noteCount === 0) { - return []; - } - - // Return root node with count - return [new RootTreeItem(noteCount)]; - } - - // Root node: return file nodes - if (element.itemType === 'root') { - return this.getFileNodes(); - } - - // File node: return note nodes - if (element.itemType === 'file') { - return this.getNoteNodes(element as FileTreeItem); - } - - // Note nodes have no children (leaf nodes) - return []; - } - - /** - * Get all file nodes (one per file with notes) - */ - private async getFileNodes(): Promise { - const notesByFile = await this.noteManager.getNotesByFile(); - const fileNodes: FileTreeItem[] = []; - const sortBy = this.getSortBy(); - - // Create file nodes - for (const [filePath, notes] of notesByFile.entries()) { - if (notes.length > 0) { - fileNodes.push(new FileTreeItem(filePath, notes, this.workspaceRoot)); - } - } - - // Sort file nodes based on configuration - switch (sortBy) { - case 'date': - // Sort by most recent note update time (descending) - fileNodes.sort((a, b) => { - const aLatest = Math.max(...a.notes.map(n => new Date(n.updatedAt).getTime())); - const bLatest = Math.max(...b.notes.map(n => new Date(n.updatedAt).getTime())); - return bLatest - aLatest; - }); - break; - - case 'author': - // Sort by author name (alphabetically), then by file path - fileNodes.sort((a, b) => { - const aAuthor = a.notes[0]?.author || ''; - const bAuthor = b.notes[0]?.author || ''; - if (aAuthor === bAuthor) { - return a.filePath.localeCompare(b.filePath); - } - return aAuthor.localeCompare(bAuthor); - }); - break; - - case 'file': - default: - // Sort alphabetically by file path - fileNodes.sort((a, b) => a.filePath.localeCompare(b.filePath)); - break; - } - - return fileNodes; - } - - /** - * Get note nodes for a file - */ - private getNoteNodes(fileNode: FileTreeItem): NoteTreeItem[] { - const previewLength = this.getPreviewLength(); - const noteNodes: NoteTreeItem[] = []; - - // Notes are already sorted by line range in getNotesByFile() - for (const note of fileNode.notes) { - noteNodes.push(new NoteTreeItem(note, previewLength)); - } - - return noteNodes; - } - - /** - * Get preview length from configuration - */ - private getPreviewLength(): number { - const config = vscode.workspace.getConfiguration('codeContextNotes'); - return config.get('sidebar.previewLength', 50); - } - - /** - * Get auto-expand setting from configuration - */ - private getAutoExpand(): boolean { - const config = vscode.workspace.getConfiguration('codeContextNotes'); - return config.get('sidebar.autoExpand', false); - } - - /** - * Get sort order from configuration - */ - private getSortBy(): 'file' | 'date' | 'author' { - const config = vscode.workspace.getConfiguration('codeContextNotes'); - return config.get<'file' | 'date' | 'author'>('sidebar.sortBy', 'file'); - } - - /** - * Dispose of all event listeners and resources - */ - dispose(): void { - // Clear debounce timer if active - if (this.debounceTimer) { - clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } - - // Dispose all event listeners - vscode.Disposable.from(...this.disposables).dispose(); - this.disposables = []; - - // Dispose the event emitter - this._onDidChangeTreeData.dispose(); - } +export class NotesSidebarProvider + implements vscode.TreeDataProvider +{ + private _onDidChangeTreeData: vscode.EventEmitter< + BaseTreeItem | undefined | null | void + > = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event< + BaseTreeItem | undefined | null | void + > = this._onDidChangeTreeData.event; + + private debounceTimer: NodeJS.Timeout | null = null; + private readonly DEBOUNCE_DELAY = 300; // ms + + // Tag filtering + private activeTagFilters: string[] = []; + private filterMode: "any" | "all" = "any"; // 'any' = OR logic, 'all' = AND logic + private disposables: vscode.Disposable[] = []; + + constructor( + private readonly noteManager: NoteManager, + private readonly workspaceRoot: string, + private readonly context: vscode.ExtensionContext + ) { + // Listen for note changes from NoteManager + this.setupEventListeners(); + + // Register dispose method so VS Code can clean up when deactivating + this.context.subscriptions.push(this); + } + + /** + * Set up event listeners for real-time updates + */ + private setupEventListeners(): void { + // Listen for note changes (create/update/delete) + const noteChangedHandler = () => { + this.refresh(); + }; + this.noteManager.on("noteChanged", noteChangedHandler); + this.disposables.push( + new vscode.Disposable(() => { + this.noteManager.removeListener("noteChanged", noteChangedHandler); + }) + ); + + // Listen for file changes (external modifications) + const noteFileChangedHandler = () => { + this.refresh(); + }; + this.noteManager.on("noteFileChanged", noteFileChangedHandler); + this.disposables.push( + new vscode.Disposable(() => { + this.noteManager.removeListener( + "noteFileChanged", + noteFileChangedHandler + ); + }) + ); + } + + /** + * Refresh the tree view (debounced for performance) + */ + refresh(): void { + // Clear existing timer + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + + // Set new debounced timer + this.debounceTimer = setTimeout(() => { + this._onDidChangeTreeData.fire(); + this.debounceTimer = null; + }, this.DEBOUNCE_DELAY); + } + + /** + * Get tree item for a node (required by TreeDataProvider) + */ + getTreeItem(element: BaseTreeItem): vscode.TreeItem { + return element; + } + + /** + * Get children for a tree node (required by TreeDataProvider) + * Implements lazy loading for performance + */ + async getChildren(element?: BaseTreeItem): Promise { + // Root level: return RootTreeItem or empty state + if (!element) { + const noteCount = await this.noteManager.getNoteCount(); + + // Empty state - no notes + if (noteCount === 0) { + return []; + } + + // Return root node with count + return [new RootTreeItem(noteCount)]; + } + + // Root node: return file nodes + if (element.itemType === "root") { + return this.getFileNodes(); + } + + // File node: return note nodes + if (element.itemType === "file") { + return this.getNoteNodes(element as FileTreeItem); + } + + // Note nodes have no children (leaf nodes) + return []; + } + + /** + * Get all file nodes (one per file with notes) + */ + private async getFileNodes(): Promise { + const notesByFile = await this.noteManager.getNotesByFile(); + const fileNodes: FileTreeItem[] = []; + const sortBy = this.getSortBy(); + + // Create file nodes, applying tag filters + for (const [filePath, notes] of notesByFile.entries()) { + // Filter notes by tags if filters are active + const filteredNotes = + this.activeTagFilters.length > 0 + ? notes.filter((note) => this.matchesTagFilter(note)) + : notes; + + // Only create file node if it has notes after filtering + if (filteredNotes.length > 0) { + fileNodes.push( + new FileTreeItem(filePath, filteredNotes, this.workspaceRoot) + ); + } + } + + // Sort file nodes based on configuration + switch (sortBy) { + case "date": + // Sort by most recent note update time (descending) + fileNodes.sort((a, b) => { + const aLatest = Math.max( + ...a.notes.map((n) => new Date(n.updatedAt).getTime()) + ); + const bLatest = Math.max( + ...b.notes.map((n) => new Date(n.updatedAt).getTime()) + ); + return bLatest - aLatest; + }); + break; + + case "author": + // Sort by author name (alphabetically), then by file path + fileNodes.sort((a, b) => { + const aAuthor = a.notes[0]?.author || ""; + const bAuthor = b.notes[0]?.author || ""; + if (aAuthor === bAuthor) { + return a.filePath.localeCompare(b.filePath); + } + return aAuthor.localeCompare(bAuthor); + }); + break; + + case "file": + default: + // Sort alphabetically by file path + fileNodes.sort((a, b) => a.filePath.localeCompare(b.filePath)); + break; + } + + return fileNodes; + } + + /** + * Get note nodes for a file + */ + private getNoteNodes(fileNode: FileTreeItem): NoteTreeItem[] { + const previewLength = this.getPreviewLength(); + const noteNodes: NoteTreeItem[] = []; + + // Notes are already sorted by line range in getNotesByFile() + for (const note of fileNode.notes) { + noteNodes.push(new NoteTreeItem(note, previewLength)); + } + + return noteNodes; + } + + /** + * Get preview length from configuration + */ + private getPreviewLength(): number { + const config = vscode.workspace.getConfiguration("codeContextNotes"); + return config.get("sidebar.previewLength", 50); + } + + /** + * Get auto-expand setting from configuration + */ + private getAutoExpand(): boolean { + const config = vscode.workspace.getConfiguration("codeContextNotes"); + return config.get("sidebar.autoExpand", false); + } + + /** + * Get sort order from configuration + */ + private getSortBy(): "file" | "date" | "author" { + const config = vscode.workspace.getConfiguration("codeContextNotes"); + return config.get<"file" | "date" | "author">("sidebar.sortBy", "file"); + } + + /** + * Set tag filters for the sidebar + */ + setTagFilters(tags: string[], mode: "any" | "all" = "any"): void { + // Normalize and deduplicate tags + const normalizedTags = [...new Set(tags.map(tag => TagManager.normalizeTag(tag)))]; + this.activeTagFilters = normalizedTags; + this.filterMode = mode; + this.refresh(); + } + + /** + * Clear all tag filters + */ + clearTagFilters(): void { + this.activeTagFilters = []; + this.refresh(); + } + + /** + * Get currently active tag filters + */ + getActiveFilters(): { tags: string[]; mode: "any" | "all" } { + return { + tags: [...this.activeTagFilters], + mode: this.filterMode, + }; + } + + /** + * Check if a note matches the active tag filters + */ + private matchesTagFilter(note: Note): boolean { + // If no filters, show all notes + if (this.activeTagFilters.length === 0) { + return true; + } + + // If note has no tags, it doesn't match any tag filter + if (!note.tags || note.tags.length === 0) { + return false; + } + + // Normalize note tags for consistent comparison + const normalizedNoteTags = note.tags.map(tag => TagManager.normalizeTag(tag)); + + // Apply filter based on mode + if (this.filterMode === "all") { + // Note must have ALL filter tags (AND logic) + return this.activeTagFilters.every((filterTag) => + normalizedNoteTags.includes(filterTag) + ); + } else { + // Note must have at least ONE filter tag (OR logic) + return this.activeTagFilters.some((filterTag) => + normalizedNoteTags.includes(filterTag) + ); + } + } + + /** + * Check if any filters are active + */ + hasActiveFilters(): boolean { + return this.activeTagFilters.length > 0; + } + + /** + * Dispose of all event listeners and resources + */ + dispose(): void { + // Clear debounce timer if active + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + + // Dispose all event listeners + vscode.Disposable.from(...this.disposables).dispose(); + this.disposables = []; + + // Dispose the event emitter + this._onDidChangeTreeData.dispose(); + } } diff --git a/src/searchUI.ts b/src/searchUI.ts new file mode 100644 index 0000000..2c14f0d --- /dev/null +++ b/src/searchUI.ts @@ -0,0 +1,719 @@ +import * as vscode from 'vscode'; +import { SearchManager } from './searchManager.js'; +import { NoteManager } from './noteManager.js'; +import { SearchQuery, SearchResult } from './searchTypes.js'; +import { Note } from './types.js'; + +/** + * QuickPick item for search results + */ +interface SearchQuickPickItem extends vscode.QuickPickItem { + note?: Note; + result?: SearchResult; + type: 'result' | 'filter' | 'action' | 'separator'; + action?: 'clearFilters' | 'showHistory' | 'advancedSearch' | 'filterByAuthor' | 'filterByDate' | 'filterByFile' | 'clearAuthorFilter' | 'clearDateFilter' | 'clearFileFilter' | 'clearCaseSensitiveFilter'; +} + +/** + * Active search filters + */ +interface SearchFilters { + authors?: string[]; + dateRange?: { + start?: Date; + end?: Date; + field: 'created' | 'updated'; + }; + filePattern?: string; + caseSensitive?: boolean; + useRegex?: boolean; +} + +/** + * Search UI using VSCode QuickPick + */ +export class SearchUI { + private searchManager: SearchManager; + private noteManager: NoteManager; + private quickPick?: vscode.QuickPick; + private activeFilters: SearchFilters = {}; + private searchDebounceTimer?: NodeJS.Timeout; + private readonly DEBOUNCE_DELAY = 200; // ms + private lastSearchQuery: string = ''; + private allNotes: Note[] = []; + + constructor(searchManager: SearchManager, noteManager: NoteManager) { + this.searchManager = searchManager; + this.noteManager = noteManager; + } + + /** + * Show the search UI + */ + async show(): Promise { + // Load all notes for searching + this.allNotes = await this.noteManager.getAllNotes(); + + // Create QuickPick + this.quickPick = vscode.window.createQuickPick(); + this.quickPick.title = '๐Ÿ” Search Notes'; + this.quickPick.placeholder = 'Type to search notes... (supports regex with /pattern/)'; + this.quickPick.matchOnDescription = true; + this.quickPick.matchOnDetail = true; + this.quickPick.canSelectMany = false; + + // Set up event handlers + this.setupEventHandlers(); + + // Show initial state + await this.updateQuickPickItems(''); + + // Show the QuickPick + this.quickPick.show(); + } + + /** + * Set up event handlers for QuickPick + */ + private setupEventHandlers(): void { + if (!this.quickPick) return; + + // Handle text input changes (live search) + this.quickPick.onDidChangeValue(async (value) => { + await this.handleSearchInput(value); + }); + + // Handle item selection + this.quickPick.onDidAccept(async () => { + await this.handleItemSelection(); + }); + + // Handle QuickPick hide + this.quickPick.onDidHide(() => { + this.cleanup(); + }); + + // Handle button clicks (if we add buttons) + this.quickPick.onDidTriggerButton((button) => { + // Future: handle custom buttons + }); + } + + /** + * Handle search input changes with debouncing + */ + private async handleSearchInput(value: string): Promise { + // Clear existing debounce timer + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + } + + // Set new debounce timer + this.searchDebounceTimer = setTimeout(async () => { + this.lastSearchQuery = value; + await this.performSearch(value); + }, this.DEBOUNCE_DELAY); + } + + /** + * Perform the actual search + */ + private async performSearch(searchText: string): Promise { + if (!this.quickPick) return; + + // Show busy indicator + this.quickPick.busy = true; + + try { + await this.updateQuickPickItems(searchText); + } catch (error) { + console.error('Search error:', error); + vscode.window.showErrorMessage(`Search failed: ${error}`); + } finally { + this.quickPick.busy = false; + } + } + + /** + * Update QuickPick items based on search and filters + */ + private async updateQuickPickItems(searchText: string): Promise { + if (!this.quickPick) return; + + const items: SearchQuickPickItem[] = []; + + // Add filter indicators if active + if (this.hasActiveFilters()) { + items.push(...this.createFilterIndicators()); + items.push(this.createSeparator()); + } + + // Perform search if there's input + if (searchText.trim().length > 0 || this.hasActiveFilters()) { + const query = this.buildSearchQuery(searchText); + const results = await this.searchManager.search(query, this.allNotes); + + // Save search to history + if (searchText.trim().length > 0) { + await this.searchManager.saveSearch(query, results.length); + } + + // Add result count + items.push({ + label: `$(search) ${results.length} result${results.length !== 1 ? 's' : ''}`, + type: 'separator', + alwaysShow: true + }); + + // Add search results + if (results.length > 0) { + items.push(...this.createResultItems(results)); + } else { + items.push({ + label: '$(info) No notes found', + description: 'Try different search terms or filters', + type: 'separator', + alwaysShow: true + }); + } + } else { + // Show search history and filter options + items.push(...await this.createDefaultItems()); + } + + // Add action items at the bottom + items.push(this.createSeparator()); + items.push(...this.createActionItems()); + + this.quickPick.items = items; + } + + /** + * Build search query from input and filters + */ + private buildSearchQuery(searchText: string): SearchQuery { + const query: SearchQuery = { + maxResults: 100 + }; + + // Parse search text for regex + const regexMatch = searchText.match(/^\/(.+)\/([gimuy]*)$/); + if (regexMatch && this.activeFilters.useRegex !== false) { + // Regex pattern + try { + query.regex = new RegExp(regexMatch[1], regexMatch[2]); + } catch (error) { + // Invalid regex, fall back to text search + query.text = searchText; + } + } else if (searchText.trim().length > 0) { + // Normal text search + query.text = searchText; + } + + // Apply filters + if (this.activeFilters.authors && this.activeFilters.authors.length > 0) { + query.authors = this.activeFilters.authors; + } + + if (this.activeFilters.dateRange) { + query.dateRange = this.activeFilters.dateRange; + } + + if (this.activeFilters.filePattern) { + query.filePattern = this.activeFilters.filePattern; + } + + if (this.activeFilters.caseSensitive) { + query.caseSensitive = true; + } + + return query; + } + + /** + * Create QuickPick items for search results + */ + private createResultItems(results: SearchResult[]): SearchQuickPickItem[] { + return results.map((result, index) => { + const note = result.note; + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const relativePath = workspaceFolder + ? vscode.workspace.asRelativePath(note.filePath) + : note.filePath; + + // Format line range + const lineInfo = note.lineRange.start === note.lineRange.end + ? `Line ${note.lineRange.start + 1}` + : `Lines ${note.lineRange.start + 1}-${note.lineRange.end + 1}`; + + // Truncate context for display + const context = result.context.length > 80 + ? result.context.substring(0, 77) + '...' + : result.context; + + // Calculate relevance indicator + const scorePercent = Math.round(result.score * 100); + const relevanceIcon = scorePercent >= 80 ? '$(star-full)' : scorePercent >= 50 ? '$(star-half)' : '$(star-empty)'; + + return { + label: `$(note) ${context}`, + description: `${relativePath} ยท ${lineInfo} ยท ${note.author}`, + detail: `${relevanceIcon} ${scorePercent}% relevance`, + type: 'result', + note, + result, + alwaysShow: true + }; + }); + } + + /** + * Create filter indicator items + */ + private createFilterIndicators(): SearchQuickPickItem[] { + const items: SearchQuickPickItem[] = []; + + items.push({ + label: '$(filter) Active Filters', + type: 'separator', + alwaysShow: true + }); + + if (this.activeFilters.authors && this.activeFilters.authors.length > 0) { + items.push({ + label: ` $(person) Authors: ${this.activeFilters.authors.join(', ')}`, + description: 'Click to remove', + type: 'filter', + action: 'clearAuthorFilter', + alwaysShow: true + }); + } + + if (this.activeFilters.dateRange) { + const { start, end, field } = this.activeFilters.dateRange; + let dateLabel = `${field === 'created' ? 'Created' : 'Updated'}: `; + if (start && end) { + dateLabel += `${start.toLocaleDateString()} to ${end.toLocaleDateString()}`; + } else if (start) { + dateLabel += `after ${start.toLocaleDateString()}`; + } else if (end) { + dateLabel += `before ${end.toLocaleDateString()}`; + } + + items.push({ + label: ` $(calendar) ${dateLabel}`, + description: 'Click to remove', + type: 'filter', + action: 'clearDateFilter', + alwaysShow: true + }); + } + + if (this.activeFilters.filePattern) { + items.push({ + label: ` $(file) Files: ${this.activeFilters.filePattern}`, + description: 'Click to remove', + type: 'filter', + action: 'clearFileFilter', + alwaysShow: true + }); + } + + if (this.activeFilters.caseSensitive) { + items.push({ + label: ` $(case-sensitive) Case Sensitive`, + description: 'Click to remove', + type: 'filter', + action: 'clearCaseSensitiveFilter', + alwaysShow: true + }); + } + + return items; + } + + /** + * Create default items (history and filters) + */ + private async createDefaultItems(): Promise { + const items: SearchQuickPickItem[] = []; + + // Add recent searches + const history = await this.searchManager.getSearchHistory(); + if (history.length > 0) { + items.push({ + label: '$(history) Recent Searches', + type: 'separator', + alwaysShow: true + }); + + for (const entry of history.slice(0, 5)) { + items.push({ + label: ` ${entry.label}`, + description: `${entry.resultCount} results ยท ${entry.timestamp.toLocaleDateString()}`, + type: 'action', + action: 'showHistory', + alwaysShow: true + }); + } + + items.push(this.createSeparator()); + } + + // Add filter suggestions + items.push({ + label: '$(info) Tips', + type: 'separator', + alwaysShow: true + }); + + items.push({ + label: ' โ€ข Type to search note content', + type: 'separator', + alwaysShow: true + }); + + items.push({ + label: ' โ€ข Use /pattern/ for regex search', + type: 'separator', + alwaysShow: true + }); + + items.push({ + label: ' โ€ข Click actions below to add filters', + type: 'separator', + alwaysShow: true + }); + + return items; + } + + /** + * Create action items (filters, clear, etc.) + */ + private createActionItems(): SearchQuickPickItem[] { + const items: SearchQuickPickItem[] = []; + + items.push({ + label: '$(add) Add Filter', + type: 'separator', + alwaysShow: true + }); + + items.push({ + label: ' $(person) Filter by Author', + description: 'Select one or more authors', + type: 'action', + action: 'filterByAuthor', + alwaysShow: true + }); + + items.push({ + label: ' $(calendar) Filter by Date Range', + description: 'Select date range', + type: 'action', + action: 'filterByDate', + alwaysShow: true + }); + + items.push({ + label: ' $(file) Filter by File Pattern', + description: 'Enter file path pattern', + type: 'action', + action: 'filterByFile', + alwaysShow: true + }); + + if (this.hasActiveFilters()) { + items.push(this.createSeparator()); + items.push({ + label: '$(clear-all) Clear All Filters', + description: 'Remove all active filters', + type: 'action', + action: 'clearFilters', + alwaysShow: true + }); + } + + return items; + } + + /** + * Create a separator item + */ + private createSeparator(): SearchQuickPickItem { + return { + label: '', + kind: vscode.QuickPickItemKind.Separator, + type: 'separator' + }; + } + + /** + * Handle item selection + */ + private async handleItemSelection(): Promise { + if (!this.quickPick) return; + + const selected = this.quickPick.selectedItems[0]; + if (!selected) return; + + // Handle different item types + switch (selected.type) { + case 'result': + await this.openNote(selected.note!); + this.quickPick.hide(); + break; + + case 'action': + await this.handleAction(selected); + break; + + case 'filter': + if (selected.action === 'clearFilters') { + await this.clearFilters(); + } + break; + } + } + + /** + * Handle action items + */ + private async handleAction(item: SearchQuickPickItem): Promise { + switch (item.action) { + case 'clearFilters': + await this.clearFilters(); + break; + case 'clearAuthorFilter': + delete this.activeFilters.authors; + await this.updateQuickPickItems(this.lastSearchQuery); + break; + case 'clearDateFilter': + delete this.activeFilters.dateRange; + await this.updateQuickPickItems(this.lastSearchQuery); + break; + case 'clearFileFilter': + delete this.activeFilters.filePattern; + await this.updateQuickPickItems(this.lastSearchQuery); + break; + case 'clearCaseSensitiveFilter': + this.activeFilters.caseSensitive = false; + await this.updateQuickPickItems(this.lastSearchQuery); + break; + case 'filterByAuthor': + await this.showAuthorFilter(); + break; + case 'filterByDate': + await this.showDateFilter(); + break; + case 'filterByFile': + await this.showFileFilter(); + break; + case 'showHistory': + // Populate search input from history item + if (this.quickPick) { + this.quickPick.value = item.label.trim(); + } + break; + } + } + + /** + * Show author filter dialog + */ + private async showAuthorFilter(): Promise { + const authors = await this.searchManager.getAuthors(); + + const selected = await vscode.window.showQuickPick( + authors.map(author => ({ + label: author, + picked: this.activeFilters.authors?.includes(author) + })), + { + canPickMany: true, + title: 'Select Authors', + placeHolder: 'Choose one or more authors to filter by' + } + ); + + if (selected) { + this.activeFilters.authors = selected.map(s => s.label); + await this.updateQuickPickItems(this.lastSearchQuery); + } + } + + /** + * Show date range filter dialog + */ + private async showDateFilter(): Promise { + // Ask for date field (created or updated) + const field = await vscode.window.showQuickPick( + [ + { label: 'Created Date', value: 'created' as const }, + { label: 'Updated Date', value: 'updated' as const } + ], + { + title: 'Filter by Date', + placeHolder: 'Which date field to filter?' + } + ); + + if (!field) return; + + // Ask for date range type + const rangeType = await vscode.window.showQuickPick( + [ + { label: 'Last 7 days', value: '7d' }, + { label: 'Last 30 days', value: '30d' }, + { label: 'Last 90 days', value: '90d' }, + { label: 'This year', value: 'year' }, + { label: 'Custom range...', value: 'custom' } + ], + { + title: 'Select Date Range', + placeHolder: 'Choose a time period' + } + ); + + if (!rangeType) return; + + let start: Date | undefined; + let end: Date | undefined = new Date(); + + if (rangeType.value === 'custom') { + // Custom date range (simplified for now) + const startStr = await vscode.window.showInputBox({ + prompt: 'Enter start date (YYYY-MM-DD)', + placeHolder: '2024-01-01' + }); + + const endStr = await vscode.window.showInputBox({ + prompt: 'Enter end date (YYYY-MM-DD)', + placeHolder: '2024-12-31' + }); + + if (startStr) start = new Date(startStr); + if (endStr) end = new Date(endStr); + } else { + // Preset ranges + const now = new Date(); + switch (rangeType.value) { + case '7d': + start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + break; + case '30d': + start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + break; + case '90d': + start = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); + break; + case 'year': + start = new Date(now.getFullYear(), 0, 1); + break; + } + } + + this.activeFilters.dateRange = { + start, + end, + field: field.value + }; + + await this.updateQuickPickItems(this.lastSearchQuery); + } + + /** + * Show file pattern filter dialog + */ + private async showFileFilter(): Promise { + const pattern = await vscode.window.showInputBox({ + prompt: 'Enter file path pattern (glob syntax)', + placeHolder: 'src/**/*.ts', + value: this.activeFilters.filePattern + }); + + if (pattern !== undefined) { + this.activeFilters.filePattern = pattern || undefined; + await this.updateQuickPickItems(this.lastSearchQuery); + } + } + + /** + * Clear all filters + */ + private async clearFilters(): Promise { + this.activeFilters = {}; + await this.updateQuickPickItems(this.lastSearchQuery); + } + + /** + * Check if any filters are active + */ + private hasActiveFilters(): boolean { + return !!( + (this.activeFilters.authors && this.activeFilters.authors.length > 0) || + this.activeFilters.dateRange || + this.activeFilters.filePattern || + this.activeFilters.caseSensitive + ); + } + + /** + * Open a note in the editor + */ + private async openNote(note: Note): Promise { + try { + // Open document + const document = await vscode.workspace.openTextDocument(note.filePath); + const editor = await vscode.window.showTextDocument(document); + + // Reveal and select the note's line range + const range = new vscode.Range( + note.lineRange.start, + 0, + note.lineRange.end, + document.lineAt(note.lineRange.end).text.length + ); + + editor.selection = new vscode.Selection(range.start, range.end); + editor.revealRange(range, vscode.TextEditorRevealType.InCenter); + + // Highlight the range temporarily + const decoration = vscode.window.createTextEditorDecorationType({ + backgroundColor: new vscode.ThemeColor('editor.findMatchHighlightBackground'), + isWholeLine: true + }); + + editor.setDecorations(decoration, [range]); + + // Remove highlight after 2 seconds + setTimeout(() => { + decoration.dispose(); + }, 2000); + + } catch (error) { + console.error('Failed to open note:', error); + vscode.window.showErrorMessage(`Failed to open note: ${error}`); + } + } + + /** + * Cleanup resources + */ + private cleanup(): void { + if (this.searchDebounceTimer) { + clearTimeout(this.searchDebounceTimer); + } + this.quickPick?.dispose(); + this.quickPick = undefined; + } + + /** + * Dispose resources + */ + dispose(): void { + this.cleanup(); + } +} diff --git a/src/tagInputUI.ts b/src/tagInputUI.ts new file mode 100644 index 0000000..189308a --- /dev/null +++ b/src/tagInputUI.ts @@ -0,0 +1,252 @@ +/** + * Tag Input UI for Code Context Notes + * Handles tag selection with autocomplete and predefined categories + */ + +import * as vscode from 'vscode'; +import { TagManager } from './tagManager.js'; +import { CATEGORY_STYLES, NoteCategory } from './tagTypes.js'; +import { Note } from './types.js'; + +export class TagInputUI { + /** + * Show tag selection UI with predefined categories and custom tag support + * @param existingTags Optional array of existing tags for editing + * @param allNotes Optional array of all notes for autocomplete suggestions + * @returns Array of selected tags, or undefined if cancelled + */ + static async showTagInput( + existingTags?: string[], + allNotes?: Note[] + ): Promise { + const quickPick = vscode.window.createQuickPick(); + quickPick.title = 'Select Tags for Note'; + quickPick.placeholder = 'Type to add custom tags or select predefined categories'; + quickPick.canSelectMany = true; + quickPick.matchOnDescription = true; + quickPick.matchOnDetail = true; + + // Get predefined categories + const predefinedCategories = TagManager.getPredefinedCategories(); + + // Get suggested tags from existing notes + const suggestedTags = allNotes ? TagManager.getSuggestedTags(allNotes, 10) : []; + + // Create items for predefined categories + const categoryItems: vscode.QuickPickItem[] = predefinedCategories.map( + (category) => { + const style = CATEGORY_STYLES[category as NoteCategory]; + return { + label: `$(tag) ${category}`, + description: style.description, + detail: style.icon ? `Icon: $(${style.icon})` : undefined, + alwaysShow: true, + }; + } + ); + + // Create items for suggested tags (excluding predefined categories) + const suggestedItems: vscode.QuickPickItem[] = suggestedTags + .filter((tag) => !TagManager.isPredefinedCategory(tag)) + .map((tag) => ({ + label: `$(tag) ${tag}`, + description: 'Custom tag (used before)', + alwaysShow: false, + })); + + // Combine all items + const items: vscode.QuickPickItem[] = [ + { + label: 'Predefined Categories', + kind: vscode.QuickPickItemKind.Separator, + }, + ...categoryItems, + ]; + + if (suggestedItems.length > 0) { + items.push( + { + label: 'Recently Used', + kind: vscode.QuickPickItemKind.Separator, + }, + ...suggestedItems + ); + } + + quickPick.items = items; + + // Pre-select existing tags if editing + if (existingTags && existingTags.length > 0) { + quickPick.selectedItems = items.filter((item) => { + const tagName = item.label.replace('$(tag) ', '').trim(); + return existingTags.includes(tagName); + }); + } + + // Handle custom tag input + quickPick.onDidChangeValue((value) => { + // If user types something not in the list, add it as a custom tag option + // Use exact matching by comparing normalized tag names + const normalizedValue = value.trim().toUpperCase(); + const exactMatch = items.some((item) => { + const tagName = item.label.replace('$(tag) ', '').trim(); + return tagName.toUpperCase() === normalizedValue; + }); + + if (value && !exactMatch) { + const customTag = value.trim(); + + // Validate the custom tag + const validation = TagManager.validateTag(customTag); + + if (validation.isValid && validation.normalizedTag) { + const normalizedTag = validation.normalizedTag; + + // Check if this tag already exists (as category, suggested tag, or custom tag) + const existingTag = items.find((item) => { + const tagLabel = item.label.replace('$(tag) ', '').trim(); + return tagLabel.toLowerCase() === normalizedTag.toLowerCase(); + }); + + // Also check if user is typing a predefined category in lowercase + if (TagManager.isPredefinedCategory(normalizedTag)) { + // Select the existing predefined category item instead of adding duplicate + const categoryItem = items.find((item) => + item.label === `$(tag) ${normalizedTag}` + ); + if (categoryItem && !quickPick.selectedItems.includes(categoryItem)) { + quickPick.selectedItems = [...quickPick.selectedItems, categoryItem]; + } + return; + } + + if (!existingTag) { + // Add custom tag option at the top (after categories) + const customTagItem: vscode.QuickPickItem = { + label: `$(tag) ${normalizedTag}`, + description: 'Custom tag (type to add)', + alwaysShow: true, + }; + + // Find where to insert (after predefined categories) + const categoryEndIndex = items.findIndex( + (item) => item.label === 'Recently Used' + ); + + if (categoryEndIndex !== -1) { + items.splice(categoryEndIndex, 0, customTagItem); + } else { + items.push(customTagItem); + } + + quickPick.items = items; + } + } + } + }); + + // Return a promise that resolves when the user makes a selection + return new Promise((resolve) => { + quickPick.onDidAccept(() => { + const selected = quickPick.selectedItems.map((item) => + item.label.replace('$(tag) ', '').trim() + ); + resolve(selected); + quickPick.hide(); + }); + + quickPick.onDidHide(() => { + resolve(undefined); + quickPick.dispose(); + }); + + quickPick.show(); + }); + } + + /** + * Show a simplified tag input for quick tagging + * @param allNotes Optional array of all notes for autocomplete suggestions + * @returns Array of selected tags, or undefined if cancelled + */ + static async showQuickTagInput(allNotes?: Note[]): Promise { + // Get predefined categories + const predefinedCategories = TagManager.getPredefinedCategories(); + + // Get suggested tags from existing notes + const suggestedTags = allNotes ? TagManager.getSuggestedTags(allNotes, 5) : []; + + // Combine suggestions + const allSuggestions = [ + ...predefinedCategories, + ...suggestedTags.filter((tag) => !TagManager.isPredefinedCategory(tag)), + ]; + + const input = await vscode.window.showInputBox({ + prompt: 'Enter tags (comma-separated)', + placeHolder: 'e.g., TODO, authentication, bug', + value: '', + valueSelection: undefined, + ignoreFocusOut: false, + title: 'Add Tags', + }); + + if (input === undefined) { + return undefined; + } + + if (!input.trim()) { + return []; + } + + // Parse and validate tags + const tags = TagManager.parseTagsFromString(input); + return tags; + } + + /** + * Show tag editor for modifying tags on an existing note + * @param note The note to edit tags for + * @param allNotes Optional array of all notes for autocomplete suggestions + * @returns Updated array of tags, or undefined if cancelled + */ + static async showTagEditor( + note: Note, + allNotes?: Note[] + ): Promise { + return this.showTagInput(note.tags, allNotes); + } + + /** + * Show a quick filter UI for filtering notes by tags + * @param allNotes Array of all notes to extract available tags from + * @returns Selected filter tags, or undefined if cancelled + */ + static async showTagFilter(allNotes: Note[]): Promise { + const allTags = TagManager.getAllTags(allNotes); + + if (allTags.length === 0) { + vscode.window.showInformationMessage('No tags found in your notes'); + return undefined; + } + + const selected = await vscode.window.showQuickPick( + allTags.map((tag) => ({ + label: tag, + picked: false, + })), + { + title: 'Filter Notes by Tags', + placeHolder: 'Select tags to filter by', + canPickMany: true, + matchOnDescription: true, + } + ); + + if (!selected) { + return undefined; + } + + return selected.map((item) => item.label); + } +} diff --git a/src/tagManager.ts b/src/tagManager.ts new file mode 100644 index 0000000..34a3628 --- /dev/null +++ b/src/tagManager.ts @@ -0,0 +1,283 @@ +/** + * Tag Manager for Code Context Notes + * Handles tag validation, normalization, and tag-related operations + */ + +import { + NoteCategory, + TagStyle, + CATEGORY_STYLES, + DEFAULT_TAG_COLOR, + TagFilterParams, + TagValidationResult, + TagStatistics, +} from './tagTypes.js'; +import { Note } from './types.js'; + +/** + * TagManager provides utilities for working with tags + */ +export class TagManager { + /** + * Get all predefined categories + */ + static getPredefinedCategories(): string[] { + return Object.values(NoteCategory); + } + + /** + * Check if a tag is a predefined category + */ + static isPredefinedCategory(tag: string): boolean { + return Object.values(NoteCategory).includes(tag as NoteCategory); + } + + /** + * Get the style for a tag (color, icon, description) + */ + static getTagStyle(tag: string): TagStyle { + if (this.isPredefinedCategory(tag)) { + return CATEGORY_STYLES[tag as NoteCategory]; + } + return { + color: DEFAULT_TAG_COLOR, + description: 'Custom tag', + }; + } + + /** + * Validate a tag + * Tags must: + * - Not be empty + * - Not contain commas (used as delimiter) + * - Not contain special characters that could break parsing + */ + static validateTag(tag: string): TagValidationResult { + if (!tag || tag.trim().length === 0) { + return { + isValid: false, + error: 'Tag cannot be empty', + }; + } + + const trimmed = tag.trim(); + + if (trimmed.includes(',')) { + return { + isValid: false, + error: 'Tag cannot contain commas', + }; + } + + // Check for other problematic characters + if (trimmed.includes('\n') || trimmed.includes('\r')) { + return { + isValid: false, + error: 'Tag cannot contain newlines', + }; + } + + if (trimmed.length > 50) { + return { + isValid: false, + error: 'Tag cannot exceed 50 characters', + }; + } + + return { + isValid: true, + normalizedTag: trimmed, + }; + } + + /** + * Normalize a tag (trim whitespace, ensure consistent casing for predefined categories) + */ + static normalizeTag(tag: string): string { + const trimmed = tag.trim(); + + // For predefined categories, ensure uppercase + const upperTag = trimmed.toUpperCase(); + if (this.isPredefinedCategory(upperTag)) { + return upperTag; + } + + // For custom tags, preserve original casing but trim + return trimmed; + } + + /** + * Validate and normalize multiple tags + */ + static validateAndNormalizeTags(tags: string[]): { + valid: string[]; + invalid: Array<{ tag: string; error: string }>; + } { + const valid: string[] = []; + const invalid: Array<{ tag: string; error: string }> = []; + + for (const tag of tags) { + const result = this.validateTag(tag); + if (result.isValid && result.normalizedTag) { + const normalized = this.normalizeTag(result.normalizedTag); + // Avoid duplicates + if (!valid.includes(normalized)) { + valid.push(normalized); + } + } else { + invalid.push({ tag, error: result.error || 'Invalid tag' }); + } + } + + return { valid, invalid }; + } + + /** + * Filter notes by tags + */ + static filterNotesByTags(notes: Note[], params: TagFilterParams): Note[] { + // Normalize filter tags + const normalizedIncludeTags = params.includeTags?.map(tag => TagManager.normalizeTag(tag)); + const normalizedExcludeTags = params.excludeTags?.map(tag => TagManager.normalizeTag(tag)); + + return notes.filter(note => { + // Normalize note tags for consistent comparison + const noteTags = (note.tags || []).map(tag => TagManager.normalizeTag(tag)); + + // Exclude notes with excluded tags + if (normalizedExcludeTags && normalizedExcludeTags.length > 0) { + const hasExcludedTag = normalizedExcludeTags.some(tag => + noteTags.includes(tag) + ); + if (hasExcludedTag) { + return false; + } + } + + // Include notes with included tags + if (normalizedIncludeTags && normalizedIncludeTags.length > 0) { + if (params.requireAllTags) { + // Note must have ALL included tags + return normalizedIncludeTags.every(tag => noteTags.includes(tag)); + } else { + // Note must have at least ONE included tag + return normalizedIncludeTags.some(tag => noteTags.includes(tag)); + } + } + + // If no include/exclude filters, return all notes + return true; + }); + } + + /** + * Get all unique tags from a collection of notes + */ + static getAllTags(notes: Note[]): string[] { + const tagSet = new Set(); + + for (const note of notes) { + if (note.tags) { + for (const tag of note.tags) { + tagSet.add(tag); + } + } + } + + return Array.from(tagSet).sort(); + } + + /** + * Get tag usage statistics + */ + static getTagStatistics(notes: Note[]): TagStatistics { + const tagCounts = new Map(); + + for (const note of notes) { + if (note.tags) { + for (const tag of note.tags) { + tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); + } + } + } + + // Sort tags by count (descending) + const topTags = Array.from(tagCounts.entries()) + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => b.count - a.count); + + return { + totalUniqueTags: tagCounts.size, + tagCounts, + topTags, + }; + } + + /** + * Parse tags from a comma-separated string + */ + static parseTagsFromString(tagsString: string): string[] { + if (!tagsString || tagsString.trim().length === 0) { + return []; + } + + const tags = tagsString.split(',').map(t => t.trim()).filter(t => t.length > 0); + const { valid } = this.validateAndNormalizeTags(tags); + return valid; + } + + /** + * Format tags for display + */ + static formatTagsForDisplay(tags: string[]): string { + if (!tags || tags.length === 0) { + return ''; + } + return tags.map(tag => `[${tag}]`).join(' '); + } + + /** + * Get suggested tags based on existing notes + * Returns tags sorted by frequency of use + */ + static getSuggestedTags(notes: Note[], limit: number = 10): string[] { + const stats = this.getTagStatistics(notes); + return stats.topTags.slice(0, limit).map(item => item.tag); + } + + /** + * Add tags to a note (avoiding duplicates) + */ + static addTagsToNote(note: Note, newTags: string[]): void { + if (!note.tags) { + note.tags = []; + } + + const { valid } = this.validateAndNormalizeTags(newTags); + + for (const tag of valid) { + if (!note.tags.includes(tag)) { + note.tags.push(tag); + } + } + } + + /** + * Remove tags from a note + */ + static removeTagsFromNote(note: Note, tagsToRemove: string[]): void { + if (!note.tags || note.tags.length === 0) { + return; + } + + note.tags = note.tags.filter(tag => !tagsToRemove.includes(tag)); + } + + /** + * Replace all tags on a note + */ + static setNoteTags(note: Note, tags: string[]): void { + const { valid } = this.validateAndNormalizeTags(tags); + note.tags = valid; + } +} diff --git a/src/tagTypes.ts b/src/tagTypes.ts new file mode 100644 index 0000000..695a61f --- /dev/null +++ b/src/tagTypes.ts @@ -0,0 +1,110 @@ +/** + * Tag and category types for Code Context Notes extension + */ + +/** + * Predefined note categories with specific meanings and visual styling + */ +export enum NoteCategory { + TODO = 'TODO', + FIXME = 'FIXME', + QUESTION = 'QUESTION', + NOTE = 'NOTE', + BUG = 'BUG', + IMPROVEMENT = 'IMPROVEMENT', + REVIEW = 'REVIEW', +} + +/** + * Visual styling configuration for a tag + */ +export interface TagStyle { + /** Color for the tag (hex or theme color) */ + color: string; + /** Optional icon ID from VSCode's codicon library */ + icon?: string; + /** Description of what this tag represents */ + description: string; +} + +/** + * Mapping of predefined categories to their visual styles + */ +export const CATEGORY_STYLES: Record = { + [NoteCategory.TODO]: { + color: '#007ACC', // Blue + icon: 'check', + description: 'Tasks that need to be completed', + }, + [NoteCategory.FIXME]: { + color: '#F14C4C', // Red + icon: 'tools', + description: 'Code that needs fixing', + }, + [NoteCategory.QUESTION]: { + color: '#FFC600', // Yellow + icon: 'question', + description: 'Questions that need answers', + }, + [NoteCategory.NOTE]: { + color: '#858585', // Gray + icon: 'note', + description: 'General notes and observations', + }, + [NoteCategory.BUG]: { + color: '#FF8C00', // Orange + icon: 'bug', + description: 'Known bugs to track', + }, + [NoteCategory.IMPROVEMENT]: { + color: '#89D185', // Green + icon: 'lightbulb', + description: 'Enhancement ideas', + }, + [NoteCategory.REVIEW]: { + color: '#C586C0', // Purple + icon: 'eye', + description: 'Code that needs review', + }, +}; + +/** + * Default color for custom tags + */ +export const DEFAULT_TAG_COLOR = '#858585'; + +/** + * Parameters for filtering notes by tags + */ +export interface TagFilterParams { + /** Tags to include (OR logic - note must have at least one) */ + includeTags?: string[]; + /** Tags to exclude (note must not have any of these) */ + excludeTags?: string[]; + /** If true, note must have ALL includeTags (AND logic) */ + requireAllTags?: boolean; +} + +/** + * Result of tag validation + */ +export interface TagValidationResult { + /** Whether the tag is valid */ + isValid: boolean; + /** Error message if invalid */ + error?: string; + /** Normalized version of the tag */ + normalizedTag?: string; +} + +/** + * Statistics about tag usage across all notes + */ +export interface TagStatistics { + /** Total number of unique tags */ + totalUniqueTags: number; + /** Map of tag to number of times it's used */ + tagCounts: Map; + /** Most frequently used tags */ + topTags: Array<{ tag: string; count: number }>; +} diff --git a/src/test/suite/tagManager.test.ts b/src/test/suite/tagManager.test.ts new file mode 100644 index 0000000..66dad4c --- /dev/null +++ b/src/test/suite/tagManager.test.ts @@ -0,0 +1,683 @@ +/** + * Unit tests for TagManager + * Tests tag validation, normalization, filtering, statistics, and tag operations + */ + +import * as assert from 'assert'; +import { TagManager } from '../../tagManager.js'; +import { NoteCategory } from '../../tagTypes.js'; +import { Note } from '../../types.js'; + +suite('TagManager Test Suite', () => { + /** + * Helper to create a mock note + */ + function createMockNote( + id: string, + content: string, + tags?: string[] + ): Note { + return { + id, + content, + author: 'Test Author', + filePath: '/workspace/test.ts', + lineRange: { start: 0, end: 0 }, + contentHash: `hash-${id}`, + createdAt: '2023-01-01T00:00:00.000Z', + updatedAt: '2023-01-01T00:00:00.000Z', + history: [], + isDeleted: false, + tags: tags || [] + }; + } + + suite('Predefined Categories', () => { + test('should return all predefined categories', () => { + const categories = TagManager.getPredefinedCategories(); + assert.strictEqual(categories.length, 7); + assert.ok(categories.includes(NoteCategory.TODO)); + assert.ok(categories.includes(NoteCategory.FIXME)); + assert.ok(categories.includes(NoteCategory.QUESTION)); + assert.ok(categories.includes(NoteCategory.NOTE)); + assert.ok(categories.includes(NoteCategory.BUG)); + assert.ok(categories.includes(NoteCategory.IMPROVEMENT)); + assert.ok(categories.includes(NoteCategory.REVIEW)); + }); + + test('should identify predefined category - exact case', () => { + assert.strictEqual(TagManager.isPredefinedCategory('TODO'), true); + assert.strictEqual(TagManager.isPredefinedCategory('FIXME'), true); + assert.strictEqual(TagManager.isPredefinedCategory('BUG'), true); + }); + + test('should not identify predefined category - wrong case', () => { + assert.strictEqual(TagManager.isPredefinedCategory('todo'), false); + assert.strictEqual(TagManager.isPredefinedCategory('Todo'), false); + assert.strictEqual(TagManager.isPredefinedCategory('fixme'), false); + }); + + test('should not identify custom tags as predefined', () => { + assert.strictEqual(TagManager.isPredefinedCategory('custom'), false); + assert.strictEqual(TagManager.isPredefinedCategory('my-tag'), false); + assert.strictEqual(TagManager.isPredefinedCategory(''), false); + }); + + test('should get style for predefined category', () => { + const todoStyle = TagManager.getTagStyle('TODO'); + assert.strictEqual(todoStyle.color, '#007ACC'); + assert.strictEqual(todoStyle.icon, 'check'); + assert.strictEqual(todoStyle.description, 'Tasks that need to be completed'); + + const bugStyle = TagManager.getTagStyle('BUG'); + assert.strictEqual(bugStyle.color, '#FF8C00'); + assert.strictEqual(bugStyle.icon, 'bug'); + }); + + test('should get default style for custom tag', () => { + const customStyle = TagManager.getTagStyle('custom'); + assert.strictEqual(customStyle.color, '#858585'); + assert.strictEqual(customStyle.description, 'Custom tag'); + assert.strictEqual(customStyle.icon, undefined); + }); + }); + + suite('Tag Validation', () => { + test('should validate valid tag', () => { + const result = TagManager.validateTag('valid-tag'); + assert.strictEqual(result.isValid, true); + assert.strictEqual(result.normalizedTag, 'valid-tag'); + assert.strictEqual(result.error, undefined); + }); + + test('should validate tag with spaces (trimmed)', () => { + const result = TagManager.validateTag(' spaced '); + assert.strictEqual(result.isValid, true); + assert.strictEqual(result.normalizedTag, 'spaced'); + }); + + test('should reject empty tag', () => { + const result = TagManager.validateTag(''); + assert.strictEqual(result.isValid, false); + assert.strictEqual(result.error, 'Tag cannot be empty'); + }); + + test('should reject whitespace-only tag', () => { + const result = TagManager.validateTag(' '); + assert.strictEqual(result.isValid, false); + assert.strictEqual(result.error, 'Tag cannot be empty'); + }); + + test('should reject tag with comma', () => { + const result = TagManager.validateTag('tag,with,commas'); + assert.strictEqual(result.isValid, false); + assert.strictEqual(result.error, 'Tag cannot contain commas'); + }); + + test('should reject tag with newline', () => { + const result = TagManager.validateTag('tag\nwith\nnewline'); + assert.strictEqual(result.isValid, false); + assert.strictEqual(result.error, 'Tag cannot contain newlines'); + }); + + test('should reject tag with carriage return', () => { + const result = TagManager.validateTag('tag\rwith\rreturn'); + assert.strictEqual(result.isValid, false); + assert.strictEqual(result.error, 'Tag cannot contain newlines'); + }); + + test('should reject tag exceeding 50 characters', () => { + const longTag = 'a'.repeat(51); + const result = TagManager.validateTag(longTag); + assert.strictEqual(result.isValid, false); + assert.strictEqual(result.error, 'Tag cannot exceed 50 characters'); + }); + + test('should accept tag with exactly 50 characters', () => { + const maxTag = 'a'.repeat(50); + const result = TagManager.validateTag(maxTag); + assert.strictEqual(result.isValid, true); + }); + + test('should accept tag with special characters (except restricted ones)', () => { + const result = TagManager.validateTag('tag-with_special.chars#123'); + assert.strictEqual(result.isValid, true); + }); + }); + + suite('Tag Normalization', () => { + test('should normalize predefined category to uppercase', () => { + assert.strictEqual(TagManager.normalizeTag('todo'), 'TODO'); + assert.strictEqual(TagManager.normalizeTag('Todo'), 'TODO'); + assert.strictEqual(TagManager.normalizeTag('TODO'), 'TODO'); + assert.strictEqual(TagManager.normalizeTag('fixme'), 'FIXME'); + assert.strictEqual(TagManager.normalizeTag('bug'), 'BUG'); + }); + + test('should preserve casing for custom tags', () => { + assert.strictEqual(TagManager.normalizeTag('MyTag'), 'MyTag'); + assert.strictEqual(TagManager.normalizeTag('custom'), 'custom'); + assert.strictEqual(TagManager.normalizeTag('Custom'), 'Custom'); + }); + + test('should trim whitespace', () => { + assert.strictEqual(TagManager.normalizeTag(' tag '), 'tag'); + assert.strictEqual(TagManager.normalizeTag(' TODO '), 'TODO'); + }); + }); + + suite('Validate and Normalize Tags', () => { + test('should validate and normalize multiple tags', () => { + const tags = ['TODO', 'custom', 'bug', 'my-tag']; + const result = TagManager.validateAndNormalizeTags(tags); + + assert.strictEqual(result.valid.length, 4); + assert.strictEqual(result.invalid.length, 0); + assert.ok(result.valid.includes('TODO')); + assert.ok(result.valid.includes('custom')); + assert.ok(result.valid.includes('BUG')); + assert.ok(result.valid.includes('my-tag')); + }); + + test('should remove duplicates', () => { + const tags = ['TODO', 'todo', 'TODO', 'custom', 'custom']; + const result = TagManager.validateAndNormalizeTags(tags); + + assert.strictEqual(result.valid.length, 2); + assert.ok(result.valid.includes('TODO')); + assert.ok(result.valid.includes('custom')); + }); + + test('should separate valid and invalid tags', () => { + const tags = ['TODO', 'tag,with,comma', 'valid', '']; + const result = TagManager.validateAndNormalizeTags(tags); + + assert.strictEqual(result.valid.length, 2); + assert.strictEqual(result.invalid.length, 2); + assert.ok(result.valid.includes('TODO')); + assert.ok(result.valid.includes('valid')); + }); + + test('should handle empty array', () => { + const result = TagManager.validateAndNormalizeTags([]); + assert.strictEqual(result.valid.length, 0); + assert.strictEqual(result.invalid.length, 0); + }); + + test('should trim and normalize before duplicate check', () => { + const tags = [' TODO ', 'todo', ' custom ', 'custom']; + const result = TagManager.validateAndNormalizeTags(tags); + + assert.strictEqual(result.valid.length, 2); + assert.ok(result.valid.includes('TODO')); + assert.ok(result.valid.includes('custom')); + }); + }); + + suite('Filter Notes by Tags', () => { + const note1 = createMockNote('note1', 'Content 1', ['TODO', 'BUG']); + const note2 = createMockNote('note2', 'Content 2', ['FIXME', 'REVIEW']); + const note3 = createMockNote('note3', 'Content 3', ['TODO', 'REVIEW']); + const note4 = createMockNote('note4', 'Content 4', ['custom']); + const note5 = createMockNote('note5', 'Content 5', []); + const notes = [note1, note2, note3, note4, note5]; + + test('should filter by single tag - OR logic', () => { + const result = TagManager.filterNotesByTags(notes, { + includeTags: ['TODO'] + }); + + assert.strictEqual(result.length, 2); + assert.ok(result.some(n => n.id === 'note1')); + assert.ok(result.some(n => n.id === 'note3')); + }); + + test('should filter by multiple tags - OR logic (default)', () => { + const result = TagManager.filterNotesByTags(notes, { + includeTags: ['TODO', 'FIXME'] + }); + + assert.strictEqual(result.length, 3); + assert.ok(result.some(n => n.id === 'note1')); + assert.ok(result.some(n => n.id === 'note2')); + assert.ok(result.some(n => n.id === 'note3')); + }); + + test('should filter by multiple tags - AND logic', () => { + const result = TagManager.filterNotesByTags(notes, { + includeTags: ['TODO', 'REVIEW'], + requireAllTags: true + }); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].id, 'note3'); + }); + + test('should filter by multiple tags - AND logic with no matches', () => { + const result = TagManager.filterNotesByTags(notes, { + includeTags: ['TODO', 'FIXME'], + requireAllTags: true + }); + + assert.strictEqual(result.length, 0); + }); + + test('should exclude notes with excluded tags', () => { + const result = TagManager.filterNotesByTags(notes, { + includeTags: ['TODO', 'FIXME', 'REVIEW'], + excludeTags: ['BUG'] + }); + + assert.strictEqual(result.length, 2); + assert.ok(result.some(n => n.id === 'note2')); + assert.ok(result.some(n => n.id === 'note3')); + assert.ok(!result.some(n => n.id === 'note1')); // Has BUG + }); + + test('should exclude notes with any excluded tag', () => { + const result = TagManager.filterNotesByTags(notes, { + excludeTags: ['TODO', 'BUG'] + }); + + // note1, note3 have TODO, note1 also has BUG - all excluded + assert.strictEqual(result.length, 3); + assert.ok(result.some(n => n.id === 'note2')); + assert.ok(result.some(n => n.id === 'note4')); + assert.ok(result.some(n => n.id === 'note5')); + }); + + test('should return all notes when no filters provided', () => { + const result = TagManager.filterNotesByTags(notes, {}); + assert.strictEqual(result.length, 5); + }); + + test('should handle notes with no tags', () => { + const result = TagManager.filterNotesByTags(notes, { + includeTags: ['TODO'] + }); + + // note5 has no tags, should not be included + assert.ok(!result.some(n => n.id === 'note5')); + }); + + test('should handle empty tag arrays in filters', () => { + const result = TagManager.filterNotesByTags(notes, { + includeTags: [], + excludeTags: [] + }); + + assert.strictEqual(result.length, 5); + }); + }); + + suite('Get All Tags', () => { + test('should get all unique tags from notes', () => { + const notes = [ + createMockNote('note1', 'Content 1', ['TODO', 'BUG']), + createMockNote('note2', 'Content 2', ['FIXME', 'TODO']), + createMockNote('note3', 'Content 3', ['custom']) + ]; + + const tags = TagManager.getAllTags(notes); + + assert.strictEqual(tags.length, 4); + assert.ok(tags.includes('BUG')); + assert.ok(tags.includes('FIXME')); + assert.ok(tags.includes('TODO')); + assert.ok(tags.includes('custom')); + }); + + test('should return sorted tags', () => { + const notes = [ + createMockNote('note1', 'Content', ['zebra', 'apple', 'middle']) + ]; + + const tags = TagManager.getAllTags(notes); + + assert.deepStrictEqual(tags, ['apple', 'middle', 'zebra']); + }); + + test('should handle notes with no tags', () => { + const notes = [ + createMockNote('note1', 'Content 1', []), + createMockNote('note2', 'Content 2') + ]; + + const tags = TagManager.getAllTags(notes); + assert.strictEqual(tags.length, 0); + }); + + test('should handle empty notes array', () => { + const tags = TagManager.getAllTags([]); + assert.strictEqual(tags.length, 0); + }); + + test('should remove duplicates across notes', () => { + const notes = [ + createMockNote('note1', 'Content', ['TODO', 'BUG']), + createMockNote('note2', 'Content', ['TODO', 'FIXME']), + createMockNote('note3', 'Content', ['BUG']) + ]; + + const tags = TagManager.getAllTags(notes); + assert.strictEqual(tags.length, 3); + }); + }); + + suite('Get Tag Statistics', () => { + test('should calculate tag statistics', () => { + const notes = [ + createMockNote('note1', 'Content', ['TODO', 'BUG']), + createMockNote('note2', 'Content', ['TODO', 'FIXME']), + createMockNote('note3', 'Content', ['TODO']), + createMockNote('note4', 'Content', ['BUG']) + ]; + + const stats = TagManager.getTagStatistics(notes); + + assert.strictEqual(stats.totalUniqueTags, 3); + assert.strictEqual(stats.tagCounts.get('TODO'), 3); + assert.strictEqual(stats.tagCounts.get('BUG'), 2); + assert.strictEqual(stats.tagCounts.get('FIXME'), 1); + }); + + test('should sort tags by count descending', () => { + const notes = [ + createMockNote('note1', 'Content', ['TODO', 'BUG']), + createMockNote('note2', 'Content', ['TODO', 'FIXME']), + createMockNote('note3', 'Content', ['TODO']) + ]; + + const stats = TagManager.getTagStatistics(notes); + + assert.strictEqual(stats.topTags[0].tag, 'TODO'); + assert.strictEqual(stats.topTags[0].count, 3); + assert.strictEqual(stats.topTags[1].tag, 'BUG'); + assert.strictEqual(stats.topTags[1].count, 1); + assert.strictEqual(stats.topTags[2].tag, 'FIXME'); + assert.strictEqual(stats.topTags[2].count, 1); + }); + + test('should handle empty notes array', () => { + const stats = TagManager.getTagStatistics([]); + + assert.strictEqual(stats.totalUniqueTags, 0); + assert.strictEqual(stats.tagCounts.size, 0); + assert.strictEqual(stats.topTags.length, 0); + }); + + test('should handle notes with no tags', () => { + const notes = [ + createMockNote('note1', 'Content', []), + createMockNote('note2', 'Content') + ]; + + const stats = TagManager.getTagStatistics(notes); + assert.strictEqual(stats.totalUniqueTags, 0); + }); + }); + + suite('Parse Tags from String', () => { + test('should parse comma-separated tags', () => { + const tags = TagManager.parseTagsFromString('TODO, BUG, custom'); + + assert.strictEqual(tags.length, 3); + assert.ok(tags.includes('TODO')); + assert.ok(tags.includes('BUG')); + assert.ok(tags.includes('custom')); + }); + + test('should trim whitespace', () => { + const tags = TagManager.parseTagsFromString(' TODO , BUG , custom '); + + assert.strictEqual(tags.length, 3); + assert.ok(tags.includes('TODO')); + }); + + test('should normalize predefined categories', () => { + const tags = TagManager.parseTagsFromString('todo, bug, fixme'); + + assert.ok(tags.includes('TODO')); + assert.ok(tags.includes('BUG')); + assert.ok(tags.includes('FIXME')); + }); + + test('should handle empty string', () => { + const tags = TagManager.parseTagsFromString(''); + assert.strictEqual(tags.length, 0); + }); + + test('should handle whitespace-only string', () => { + const tags = TagManager.parseTagsFromString(' '); + assert.strictEqual(tags.length, 0); + }); + + test('should skip invalid tags', () => { + const tags = TagManager.parseTagsFromString('TODO, , valid, '); + + assert.strictEqual(tags.length, 2); + assert.ok(tags.includes('TODO')); + assert.ok(tags.includes('valid')); + }); + + test('should remove duplicates', () => { + const tags = TagManager.parseTagsFromString('TODO, todo, TODO, custom, custom'); + + assert.strictEqual(tags.length, 2); + assert.ok(tags.includes('TODO')); + assert.ok(tags.includes('custom')); + }); + }); + + suite('Format Tags for Display', () => { + test('should format tags with brackets', () => { + const formatted = TagManager.formatTagsForDisplay(['TODO', 'BUG', 'custom']); + assert.strictEqual(formatted, '[TODO] [BUG] [custom]'); + }); + + test('should handle single tag', () => { + const formatted = TagManager.formatTagsForDisplay(['TODO']); + assert.strictEqual(formatted, '[TODO]'); + }); + + test('should handle empty array', () => { + const formatted = TagManager.formatTagsForDisplay([]); + assert.strictEqual(formatted, ''); + }); + + test('should handle undefined', () => { + const formatted = TagManager.formatTagsForDisplay(undefined as any); + assert.strictEqual(formatted, ''); + }); + }); + + suite('Get Suggested Tags', () => { + test('should suggest most used tags', () => { + const notes = [ + createMockNote('note1', 'Content', ['TODO', 'BUG']), + createMockNote('note2', 'Content', ['TODO', 'FIXME']), + createMockNote('note3', 'Content', ['TODO']), + createMockNote('note4', 'Content', ['BUG', 'REVIEW']) + ]; + + const suggested = TagManager.getSuggestedTags(notes, 3); + + assert.strictEqual(suggested.length, 3); + assert.strictEqual(suggested[0], 'TODO'); // count: 3 + assert.strictEqual(suggested[1], 'BUG'); // count: 2 + }); + + test('should limit suggestions to specified limit', () => { + const notes = [ + createMockNote('note1', 'Content', ['tag1', 'tag2', 'tag3', 'tag4', 'tag5']) + ]; + + const suggested = TagManager.getSuggestedTags(notes, 2); + assert.strictEqual(suggested.length, 2); + }); + + test('should use default limit of 10', () => { + const notes = Array.from({ length: 15 }, (_, i) => + createMockNote(`note${i}`, 'Content', [`tag${i}`]) + ); + + const suggested = TagManager.getSuggestedTags(notes); + assert.ok(suggested.length <= 10); + }); + + test('should handle empty notes array', () => { + const suggested = TagManager.getSuggestedTags([]); + assert.strictEqual(suggested.length, 0); + }); + }); + + suite('Add Tags to Note', () => { + test('should add new tags to note', () => { + const note = createMockNote('note1', 'Content', ['TODO']); + TagManager.addTagsToNote(note, ['BUG', 'custom']); + + assert.strictEqual(note.tags!.length, 3); + assert.ok(note.tags!.includes('TODO')); + assert.ok(note.tags!.includes('BUG')); + assert.ok(note.tags!.includes('custom')); + }); + + test('should avoid adding duplicate tags', () => { + const note = createMockNote('note1', 'Content', ['TODO']); + TagManager.addTagsToNote(note, ['TODO', 'BUG']); + + assert.strictEqual(note.tags!.length, 2); + assert.ok(note.tags!.includes('TODO')); + assert.ok(note.tags!.includes('BUG')); + }); + + test('should normalize tags before adding', () => { + const note = createMockNote('note1', 'Content', ['TODO']); + TagManager.addTagsToNote(note, ['todo', 'bug']); + + assert.strictEqual(note.tags!.length, 2); + assert.ok(note.tags!.includes('TODO')); + assert.ok(note.tags!.includes('BUG')); + }); + + test('should skip invalid tags', () => { + const note = createMockNote('note1', 'Content', []); + TagManager.addTagsToNote(note, ['TODO', 'tag,with,comma', 'valid']); + + assert.strictEqual(note.tags!.length, 2); + assert.ok(note.tags!.includes('TODO')); + assert.ok(note.tags!.includes('valid')); + }); + + test('should initialize tags array if undefined', () => { + const note = createMockNote('note1', 'Content'); + note.tags = undefined; + TagManager.addTagsToNote(note, ['TODO']); + + assert.ok(note.tags); + assert.strictEqual(note.tags!.length, 1); + }); + + test('should handle empty tags array', () => { + const note = createMockNote('note1', 'Content', ['TODO']); + TagManager.addTagsToNote(note, []); + + assert.strictEqual(note.tags!.length, 1); + }); + }); + + suite('Remove Tags from Note', () => { + test('should remove specified tags', () => { + const note = createMockNote('note1', 'Content', ['TODO', 'BUG', 'custom']); + TagManager.removeTagsFromNote(note, ['BUG']); + + assert.strictEqual(note.tags!.length, 2); + assert.ok(note.tags!.includes('TODO')); + assert.ok(note.tags!.includes('custom')); + assert.ok(!note.tags!.includes('BUG')); + }); + + test('should remove multiple tags', () => { + const note = createMockNote('note1', 'Content', ['TODO', 'BUG', 'custom']); + TagManager.removeTagsFromNote(note, ['TODO', 'BUG']); + + assert.strictEqual(note.tags!.length, 1); + assert.ok(note.tags!.includes('custom')); + }); + + test('should handle removing non-existent tags', () => { + const note = createMockNote('note1', 'Content', ['TODO']); + TagManager.removeTagsFromNote(note, ['BUG', 'nonexistent']); + + assert.strictEqual(note.tags!.length, 1); + assert.ok(note.tags!.includes('TODO')); + }); + + test('should handle note with no tags', () => { + const note = createMockNote('note1', 'Content', []); + TagManager.removeTagsFromNote(note, ['TODO']); + + assert.strictEqual(note.tags!.length, 0); + }); + + test('should handle note with undefined tags', () => { + const note = createMockNote('note1', 'Content'); + note.tags = undefined; + TagManager.removeTagsFromNote(note, ['TODO']); + + // Should not throw error + assert.ok(true); + }); + + test('should handle empty removal array', () => { + const note = createMockNote('note1', 'Content', ['TODO', 'BUG']); + TagManager.removeTagsFromNote(note, []); + + assert.strictEqual(note.tags!.length, 2); + }); + }); + + suite('Set Note Tags', () => { + test('should replace all tags', () => { + const note = createMockNote('note1', 'Content', ['TODO', 'BUG']); + TagManager.setNoteTags(note, ['FIXME', 'custom']); + + assert.strictEqual(note.tags!.length, 2); + assert.ok(note.tags!.includes('FIXME')); + assert.ok(note.tags!.includes('custom')); + assert.ok(!note.tags!.includes('TODO')); + assert.ok(!note.tags!.includes('BUG')); + }); + + test('should normalize tags', () => { + const note = createMockNote('note1', 'Content', []); + TagManager.setNoteTags(note, ['todo', 'bug']); + + assert.ok(note.tags!.includes('TODO')); + assert.ok(note.tags!.includes('BUG')); + }); + + test('should remove duplicates', () => { + const note = createMockNote('note1', 'Content', []); + TagManager.setNoteTags(note, ['TODO', 'todo', 'BUG', 'bug']); + + assert.strictEqual(note.tags!.length, 2); + }); + + test('should skip invalid tags', () => { + const note = createMockNote('note1', 'Content', []); + TagManager.setNoteTags(note, ['TODO', 'tag,comma', 'valid']); + + assert.strictEqual(note.tags!.length, 2); + assert.ok(note.tags!.includes('TODO')); + assert.ok(note.tags!.includes('valid')); + }); + + test('should clear tags when given empty array', () => { + const note = createMockNote('note1', 'Content', ['TODO', 'BUG']); + TagManager.setNoteTags(note, []); + + assert.strictEqual(note.tags!.length, 0); + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index 90ff026..39afce2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -55,6 +55,8 @@ export interface Note { history: NoteHistoryEntry[]; /** Whether this note has been deleted (soft delete) */ isDeleted?: boolean; + /** Tags associated with this note (both predefined and custom) */ + tags?: string[]; } /** @@ -113,6 +115,8 @@ export interface CreateNoteParams { content: string; /** Optional author override */ author?: string; + /** Optional tags for the note */ + tags?: string[]; } /** @@ -125,6 +129,8 @@ export interface UpdateNoteParams { content: string; /** Optional author override */ author?: string; + /** Optional tags update */ + tags?: string[]; } /**