diff --git a/README.md b/README.md index 6b4e9a7..e13cb47 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,16 @@ Enhance your Obsidian workflow with a powerful status tracking system for your n ![Hello World Screenshot](images/hello-world.png) +## Table of Contents +- [Features](#features) +- [Installation](#installation) +- [User Guide](#user-guide) +- [Configuration](#configuration) +- [Performance Recommendations](#performance-recommendations) +- [Development](#development) +- [Roadmap](#roadmap) +- [Support](#support-the-development) + ## Features - **Status Assignment**: Mark notes with statuses (active, on hold, completed, dropped) - **Multiple Statuses**: Apply more than one status to a single note @@ -17,12 +27,11 @@ Enhance your Obsidian workflow with a powerful status tracking system for your n - **Custom Statuses**: Create your own statuses with icons and colors - **Status Templates**: Choose from predefined templates or create your own - **Highly Customizable**: Configure where and how statuses appear +- **Large Vault Support**: Optimized for performance with pagination and filtering options ## Installation -> Coming soon to the Obsidian Community Plugins marketplace! ### Marketplace Installation (Recommended) -Once available in the marketplace: 1. Open Obsidian → Settings → Community plugins 2. Disable Safe mode 3. Click "Browse" and search for "Note Status" @@ -99,6 +108,7 @@ To add additional statuses: 3. Click on an active status to remove it #### Batch Updates + To update multiple files at once: 1. Select multiple files in the file explorer (using Ctrl/Cmd or Shift) 2. Right-click and choose "Change status" @@ -107,9 +117,17 @@ To update multiple files at once: ![Batch Updates](images/batch-updates.png) +#### Large Vault Performance +If you have a large vault with thousands of notes, use these features for better performance: + +1. Enable "Exclude unassigned notes from status pane" in settings +2. Use the search function to filter notes ![Show Unassigned Notes](images/show-unassigned-notes.png) +3. Use pagination controls to navigate through large status groups ![Pagination](images/pagination.png) + ## Configuration ### Status Management Access plugin settings via Settings → Note Status + #### Status Templates Choose from predefined status templates: @@ -137,10 +155,20 @@ Configure how statuses are displayed: - Auto-hide status bar when status is unknown - Show/hide status icons in file explorer - Hide unknown status in file explorer +- **Exclude unassigned notes from status pane** (recommended for large vaults) - Toggle compact view in status pane - Enable/disable multiple statuses mode - Customize frontmatter tag name +## Performance Recommendations +If you have a large vault (1000+ notes), consider these settings for optimal performance: + +1. Enable "Exclude unassigned notes from status pane" +2. Enable "Hide unknown status in file explorer" +3. Use specific searches rather than browsing all notes +4. Consider using "Compact view" in the status pane + + ## Commands The plugin provides several commands accessible via the Command Palette: - `Open status pane` - Opens the status view @@ -236,7 +264,6 @@ Contributions welcome! Please feel free to submit a Pull Request. The following features and improvements are planned for upcoming releases: ### Short-term -- **Bug Fix**: Resolve selection context issue when changing status of a single file while multiple files are selected in the explorer view - **Batch Modification Enhancement**: Implement a modal dialog with file preview and status selection for more intuitive batch operations - **Dropdown Refinement**: Restructure status options to be grouped by categories (workflow, templates, custom) with visual separators - **Obsidian API Compliance**: Refactor code to follow Obsidian guidelines for better performance and compatibility: @@ -251,3 +278,8 @@ The following features and improvements are planned for upcoming releases: - **Canvas integration**: Show status on canvas cards - **Graph view integration**: Visualize notes by status in graph view - **Mobile optimization**: Improved experience on mobile devices + +## Support the Development +If you find this plugin useful and would like to support its development, you can make a donation through my PayPal account. Any contribution is greatly appreciated and helps me continue improving the plugin! + +PayPal: https://paypal.me/aleixsoler diff --git a/constants/defaults.ts b/constants/defaults.ts index f8df980..d98016f 100644 --- a/constants/defaults.ts +++ b/constants/defaults.ts @@ -22,7 +22,8 @@ export const DEFAULT_SETTINGS: NoteStatusSettings = { enabledTemplates: DEFAULT_ENABLED_TEMPLATES, useCustomStatusesOnly: false, useMultipleStatuses: true, - tagPrefix: 'obsidian-note-status' + tagPrefix: 'obsidian-note-status', + excludeUnknownStatus: true, // Default to exclude unknown status files for better performance }; /** diff --git a/images/pagination.png b/images/pagination.png new file mode 100644 index 0000000..4432ccd Binary files /dev/null and b/images/pagination.png differ diff --git a/images/show-unassigned-notes.png b/images/show-unassigned-notes.png new file mode 100644 index 0000000..1c5ceae Binary files /dev/null and b/images/show-unassigned-notes.png differ diff --git a/main.ts b/main.ts index ea19a40..bbe0189 100644 --- a/main.ts +++ b/main.ts @@ -63,20 +63,76 @@ export default class NoteStatus extends Plugin { try { // Load settings first before initializing other components await this.loadSettings(); - + this.boundSaveSettings = this.saveSettings.bind(this); this.boundCheckNoteStatus = this.checkNoteStatus.bind(this); this.boundRefreshDropdown = () => this.statusDropdown?.render(); this.boundRefreshUI = () => this.checkNoteStatus(); - - // Then initialize the rest of the plugin - await this.initialize(); + + // Register icons and essential services first + this.registerIcons(); + this.statusService = new StatusService(this.app, this.settings); + this.styleService = new StyleService(this.settings); + + // Register views and commands right away + this.registerViews(); + this.registerCommands(); + + // Add settings tab + this.addSettingTab(new NoteStatusSettingTab(this.app, this)); + + // Set up custom events early + this.setupCustomEvents(); + + // Delay UI-heavy initialization until the layout is ready + this.app.workspace.onLayoutReady(async () => { + // Split initialization into phases for better performance + await this.initializeUIComponents(); + }); } catch (error) { console.error('Error loading Note Status plugin:', error); new Notice('Error loading Note Status plugin. Check console for details.'); } } + private async initializeUIComponents(): Promise { + // Initialize UI components + this.statusBar = new StatusBar(this.addStatusBarItem(), this.settings, this.statusService); + this.statusDropdown = new StatusDropdown(this.app, this.settings, this.statusService); + + // Initialize explorer integration with a slight delay + setTimeout(() => { + this.explorerIntegration = new ExplorerIntegration(this.app, this.settings, this.statusService); + this.statusContextMenu = new StatusContextMenu( + this.app, + this.settings, + this.statusService, + this.statusDropdown, + this.explorerIntegration + ); + + // Register events after explorer integration is ready + this.registerMenuHandlers(); + this.registerEvents(); + + // Check status for active file only initially + this.checkNoteStatus(); + + // Update only active file icon initially for better performance + const activeFile = this.app.workspace.getActiveFile(); + if (activeFile && this.settings.showStatusIconsInExplorer) { + this.explorerIntegration.updateFileExplorerIcons(activeFile); + } + + // Delay full explorer icon update to avoid startup lag + if (this.settings.showStatusIconsInExplorer) { + setTimeout(() => { + this.explorerIntegration.updateAllFileExplorerIcons(); + }, 2000); + } + }, 300); + } + /** * Initialize the plugin components */ @@ -509,18 +565,20 @@ export default class NoteStatus extends Plugin { /** * Open the status pane */ - async openStatusPane(): Promise { + public async openStatusPane(): Promise { try { + // Check if already open const existing = this.app.workspace.getLeavesOfType('status-pane')[0]; if (existing) { this.app.workspace.setActiveLeaf(existing); - await this.updateStatusPane(); - } else { - const leaf = this.app.workspace.getLeftLeaf(false); - if (leaf) { - await leaf.setViewState({ type: 'status-pane', active: true }); - this.statusPaneLeaf = leaf; - } + return; + } + + // Create a new leaf and show loading state + const leaf = this.app.workspace.getLeftLeaf(false); + if (leaf) { + await leaf.setViewState({ type: 'status-pane', active: true }); + this.statusPaneLeaf = leaf; } } catch (error) { console.error('Error opening status pane:', error); diff --git a/models/types.ts b/models/types.ts index 1a57e9e..820bfc3 100644 --- a/models/types.ts +++ b/models/types.ts @@ -26,6 +26,7 @@ export interface NoteStatusSettings { useCustomStatusesOnly: boolean; // Whether to use only custom statuses or include templates useMultipleStatuses: boolean; // Whether to allow multiple statuses per note tagPrefix: string; // Prefix for the status tag (default: 'status') + excludeUnknownStatus: boolean; // Whether to exclude files with unknown status from the status pane } /** diff --git a/package.json b/package.json index 6a00766..584d18b 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "esbuild": "0.17.3", "obsidian": "latest", "tslib": "2.4.0", - "typescript": "4.7.4" + "typescript": "4.7.4", + "commander": "13.1.0" } } diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..593ff7e --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,34 @@ +## Installation and Usage + +1. Install dependencies: +```bash + npm install commander +``` +2. Install tsx for executing TypeScript directly: +```bash + npm install -g tsx +``` +3. Run the script: +```bash + tsx generate-test-vault.ts --count 40000 --output ./test-vault +``` + +## Script Options + +- `--count` or `-c`: Number of notes to generate (default: 40000) +- `--output` or `-o`: Output directory path (default: ./test-vault) +- `--tag-prefix` or `-t`: Status tag prefix (default: obsidian-note-status) +- `--depth` or `-d`: Maximum folder depth (default: 5) +- `--max-per-folder` or `-m`: Maximum files per folder (default: 200) +- `--no-status` or `-n`: Generate notes without status tags (default: false) + +## Performance Considerations +The script creates a realistic vault structure with: + +- Multiple folder levels +- Random distribution of notes across folders +- Variety of statuses including single and multiple status notes +- Varied note content length and structure +- Proper frontmatter format matching your plugin's expectations + +This will let you test real-world performance issues with your plugin without needing to manually create thousands of notes. \ No newline at end of file diff --git a/scripts/generate-test-vault.ts b/scripts/generate-test-vault.ts new file mode 100644 index 0000000..1f98b44 --- /dev/null +++ b/scripts/generate-test-vault.ts @@ -0,0 +1,249 @@ +#!/usr/bin/env tsx +/** + * Obsidian Test Vault Generator + * + * This script generates a large number of markdown files with status tags + * to test the performance of the Note Status plugin with large vaults. + * + * Usage: + * npx tsx generate-test-vault.ts --count 40000 --output ./test-vault + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { program } from 'commander'; + +// Define command line options +program + .option('-c, --count ', 'Number of notes to generate', '40000') + .option('-o, --output ', 'Output directory path', './test-vault') + .option('-t, --tag-prefix ', 'Status tag prefix', 'obsidian-note-status') + .option('-d, --depth ', 'Maximum folder depth', '5') + .option('-m, --max-per-folder ', 'Maximum files per folder', '200') + .option('-n, --no-status', 'Generate notes without status tags') + .parse(process.argv); + +const options = program.opts(); + +// Validate options +const noteCount = parseInt(options.count, 10); +const outputDir = options.output; +const tagPrefix = options.tagPrefix; +const maxDepth = parseInt(options.depth, 10); +const maxPerFolder = parseInt(options.maxPerFolder, 10); +const includeStatus = options.status !== false; // true by default, false if --no-status is used + +if (isNaN(noteCount) || noteCount <= 0) { + console.error('Error: Note count must be a positive number'); + process.exit(1); +} + +// Define possible statuses (matching your plugin's defaults) +const statuses = [ + 'active', + 'onHold', + 'completed', + 'dropped', + 'unknown', + // Add some from the colorful template + 'idea', + 'draft', + 'inProgress', + 'editing', + 'pending' +]; + +// Define topics for more realistic note titles +const topics = [ + 'Project', 'Meeting', 'Research', 'Idea', 'Book', 'Journal', + 'Task', 'Recipe', 'Person', 'Place', 'Event', 'Course', + 'Paper', 'Review', 'Analysis', 'Summary', 'Plan', 'Design', + 'Budget', 'Report', 'Feature', 'Bug', 'Release', 'Backlog', + 'Sprint', 'Goal', 'OKR', 'KPI', 'Metric', 'Reflection' +]; + +// Generate a random status +function getRandomStatus(): string { + const multipleStatuses = Math.random() > 0.8; // 20% chance of multiple statuses + + if (multipleStatuses) { + const count = Math.floor(Math.random() * 2) + 2; // 2-3 statuses + const selectedStatuses = new Set(); + + while (selectedStatuses.size < count) { + selectedStatuses.add(statuses[Math.floor(Math.random() * statuses.length)]); + } + + return JSON.stringify(Array.from(selectedStatuses)); + } else { + return JSON.stringify([statuses[Math.floor(Math.random() * statuses.length)]]); + } +} + +// Generate a random title +function generateTitle(): string { + const topic = topics[Math.floor(Math.random() * topics.length)]; + const qualifier = Math.random() > 0.5 ? + ['New', 'Important', 'Urgent', 'Weekly', 'Monthly', 'Daily', 'Annual'][Math.floor(Math.random() * 7)] : ''; + const number = Math.random() > 0.3 ? Math.floor(Math.random() * 1000).toString() : ''; + + return [qualifier, topic, number].filter(Boolean).join(' ').trim(); +} + +// Generate note content with random length +function generateContent(): string { + const paragraphCount = Math.floor(Math.random() * 5) + 1; + let content = ''; + + for (let i = 0; i < paragraphCount; i++) { + const sentenceCount = Math.floor(Math.random() * 5) + 1; + let paragraph = ''; + + for (let j = 0; j < sentenceCount; j++) { + const wordCount = Math.floor(Math.random() * 15) + 5; + const sentence = Array(wordCount).fill('lorem').join(' ') + '. '; + paragraph += sentence; + } + + content += paragraph + '\n\n'; + } + + return content; +} + +// Create a note with optional frontmatter containing status +function createNote(title: string, folderPath: string): void { + const safeName = title.replace(/[^a-zA-Z0-9 ]/g, '').replace(/\s+/g, ' '); + const fileName = `${safeName}.md`; + const filePath = path.join(folderPath, fileName); + + let fileContent = ''; + + if (includeStatus) { + // Create frontmatter with random status + const status = getRandomStatus(); + fileContent = `---\n${tagPrefix}: ${status}\n---\n\n`; + } + + // Create content + fileContent += `# ${title}\n\n${generateContent()}`; + + // Write file + fs.writeFileSync(filePath, fileContent); +} + +// Generate a folder structure and notes +function generateNotes(count: number, baseDir: string): void { + // Create base directory if it doesn't exist + if (!fs.existsSync(baseDir)) { + fs.mkdirSync(baseDir, { recursive: true }); + } + + // Track created files + let created = 0; + let foldersCreated = 0; + + // Create a function to generate notes in a folder with recursion + function generateNotesInFolder(folderPath: string, depth: number): void { + if (created >= count) return; + + // Determine how many files to create in this folder + const filesInThisFolder = Math.min( + Math.floor(Math.random() * maxPerFolder) + 1, + count - created + ); + + // Create files in this folder + for (let i = 0; i < filesInThisFolder; i++) { + if (created >= count) break; + + const title = generateTitle(); + createNote(title, folderPath); + created++; + + // Display progress + if (created % 1000 === 0 || created === count) { + console.log(`Created ${created}/${count} notes...`); + } + } + + // Possibly create subfolders if not at max depth + if (depth < maxDepth && created < count) { + // Decide how many subfolders to create + const subfoldersCount = Math.floor(Math.random() * 5) + 1; + + for (let i = 0; i < subfoldersCount; i++) { + if (created >= count) break; + + const folderName = `Folder ${++foldersCreated}`; + const subfolderPath = path.join(folderPath, folderName); + + // Create subfolder + if (!fs.existsSync(subfolderPath)) { + fs.mkdirSync(subfolderPath); + } + + // Recursively generate notes in subfolder + generateNotesInFolder(subfolderPath, depth + 1); + } + } + } + + // Start generation + generateNotesInFolder(baseDir, 0); + + console.log(`\nSuccessfully created ${created} notes in ${foldersCreated} folders.`); + console.log(`Output directory: ${path.resolve(baseDir)}`); +} + +// Create .obsidian folder with minimal config to ensure it's recognized as a vault +function createObsidianConfig(baseDir: string): void { + const obsidianDir = path.join(baseDir, '.obsidian'); + if (!fs.existsSync(obsidianDir)) { + fs.mkdirSync(obsidianDir, { recursive: true }); + } + + // Create a minimal config.json + const configPath = path.join(obsidianDir, 'config.json'); + const config = { + "baseFontSize": 16, + "pluginEnabledStatus": { + "file-explorer": true, + "global-search": true, + "switcher": true, + "graph": true, + "backlink": true, + "page-preview": true, + "templates": true + } + }; + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + + // Create a minimal appearance.json + const appearancePath = path.join(obsidianDir, 'appearance.json'); + const appearance = { + "baseFontSize": 16, + "theme": "obsidian" + }; + + fs.writeFileSync(appearancePath, JSON.stringify(appearance, null, 2)); +} + +// Main execution +console.log(`Generating ${noteCount} notes in ${outputDir}${includeStatus ? ` with tag prefix '${tagPrefix}'` : ' without status tags'}`); +console.time('Generation completed in'); + +// Create test vault +generateNotes(noteCount, outputDir); + +// Create Obsidian configuration +createObsidianConfig(outputDir); + +console.timeEnd('Generation completed in'); +console.log('\nInstructions:'); +console.log('1. Open Obsidian'); +console.log('2. Select "Open folder as vault"'); +console.log(`3. Navigate to: ${path.resolve(outputDir)}`); +console.log('4. Install and enable your Note Status plugin'); +console.log('5. Test the plugin performance with this large vault'); \ No newline at end of file diff --git a/settings/settings-tab.ts b/settings/settings-tab.ts index 9286053..928abe3 100644 --- a/settings/settings-tab.ts +++ b/settings/settings-tab.ts @@ -82,6 +82,24 @@ export class NoteStatusSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); + // Add option to exclude unknown status in pane view for performance + new Setting(containerEl) + .setName('Exclude unassigned notes from status pane') + .setDesc('Improves performance by excluding notes with no assigned status from the status pane. Recommended for large vaults.') + .addToggle(toggle => toggle + .setValue(this.plugin.settings.excludeUnknownStatus || false) + .onChange(async (value) => { + this.plugin.settings.excludeUnknownStatus = value; + await this.plugin.saveSettings(); + + // Refresh the status pane if open + const statusPane = this.app.workspace.getLeavesOfType('status-pane')[0]; + if (statusPane && statusPane.view) { + // Trigger refresh + window.dispatchEvent(new CustomEvent('note-status:refresh-ui')); + } + })); + new Setting(containerEl).setName('Status tag').setHeading(); // Option to use multiple statuses diff --git a/styles.css b/styles.css index 3a0e827..e526f25 100644 --- a/styles.css +++ b/styles.css @@ -1210,4 +1210,139 @@ border-radius: 12px; background: var(--background-secondary); font-size: 0.85em; +} + +/* Pagination styles */ +.note-status-pagination { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + border-top: 1px solid var(--background-modifier-border); + margin-top: 8px; +} + +.note-status-pagination-button { + padding: 4px 8px; + border-radius: var(--radius-s); + background: var(--background-secondary); + border: var(--input-border-width) solid var(--background-modifier-border); + color: var(--text-normal); + cursor: pointer; + transition: all 0.15s var(--ease-out); + font-size: var(--font-smaller); +} + +.note-status-pagination-button:hover { + background: var(--background-modifier-hover); + box-shadow: var(--shadow-s); +} + +.note-status-pagination-info { + font-size: var(--font-smaller); + color: var(--text-muted); +} + +/* Loading indicator */ +.note-status-loading { + display: flex; + justify-content: center; + align-items: center; + padding: 20px; + color: var(--text-muted); + font-style: italic; +} + +.note-status-loading span { + position: relative; + padding-left: 24px; +} + +.note-status-loading span:before { + content: ''; + position: absolute; + left: 0; + top: 50%; + width: 16px; + height: 16px; + margin-top: -8px; + border: 2px solid var(--background-modifier-border); + border-top-color: var(--text-accent); + border-radius: 50%; + animation: note-status-loading-spinner 0.8s linear infinite; +} + +@keyframes note-status-loading-spinner { + to { + transform: rotate(360deg); + } +} + +/* Improve status bar styles to fix any issues */ +.note-status-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: var(--radius-s); + transition: all 0.2s var(--ease-out); + cursor: pointer; + height: 22px; /* Consistent height */ +} + +/* Make sure icon container doesn't disrupt layout */ +.note-status-icon-container { + display: inline-flex; + margin-left: 4px; + gap: 2px; + align-items: center; + flex-shrink: 0; + position: relative; /* Use relative positioning */ + z-index: 1; /* Keep on top */ +} + +/* Fix title content to ensure it's always visible */ +.nav-file-title-content { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 4px; /* Add margin to prevent overlap */ +} + +.note-status-empty-message { + margin-bottom: 12px; + text-align: center; + color: var(--text-muted); + font-style: italic; +} + +.note-status-button-container { + display: flex; + justify-content: center; + margin-top: 16px; +} + +.note-status-show-unassigned-button { + background-color: var(--interactive-accent); + color: var(--text-on-accent); + padding: 8px 16px; + border-radius: var(--radius-s, 4px); + border: none; + cursor: pointer; + font-size: var(--font-ui-small); + transition: all 0.2s var(--ease-out); + box-shadow: var(--shadow-s); +} + +.note-status-show-unassigned-button:hover { + background-color: var(--interactive-accent-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-m); +} + +.note-status-show-unassigned-button:active { + transform: translateY(0); + box-shadow: var(--shadow-xs); } \ No newline at end of file diff --git a/ui/components/status-dropdown-component.ts b/ui/components/status-dropdown-component.ts index 28fdf15..8d8ffc5 100644 --- a/ui/components/status-dropdown-component.ts +++ b/ui/components/status-dropdown-component.ts @@ -337,13 +337,17 @@ export class StatusDropdownComponent { // Add remove animation chipEl.addClass('note-status-chip-removing'); - // Only proceed with removal if we have a target file - if (this.targetFile) { - // Wait for animation to complete before actually removing - setTimeout(async () => { + // Wait for animation to complete before actually removing + setTimeout(async () => { + if (this.targetFile) { + // Remove status from single file await this.removeStatus(status); - }, 150); - } + } else { + // This is a batch operation - remove from all files + // Call the onStatusChange callback with the status to be removed + this.onStatusChange([status]); + } + }, 150); }); } diff --git a/ui/components/status-dropdown.ts b/ui/components/status-dropdown.ts index 79a60ce..88a972e 100644 --- a/ui/components/status-dropdown.ts +++ b/ui/components/status-dropdown.ts @@ -338,6 +338,48 @@ export class StatusDropdown { this._openStatusDropdown(options); } + /** + * Find common statuses across multiple files + */ + private findCommonStatuses(files: TFile[]): string[] { + if (files.length === 0) return ['unknown']; + + // Get statuses for first file + const firstFileStatuses = this.statusService.getFileStatuses(files[0]); + + // Filter to only include statuses that exist on all files + return firstFileStatuses.filter(status => { + // Skip unknown status + if (status === 'unknown') return false; + + // Check if status exists on all files + return files.every(file => + this.statusService.getFileStatuses(file).includes(status) + ); + }); + } + + /** + * Toggle a status across multiple files + */ + private async toggleStatusForFiles(files: TFile[], status: string): Promise { + // Count how many files have this status + const filesWithStatus = files.filter(file => + this.statusService.getFileStatuses(file).includes(status) + ); + + // If more than half have the status, remove it; otherwise, add it + const shouldRemove = filesWithStatus.length > files.length / 2; + + for (const file of files) { + if (shouldRemove) { + await this.statusService.removeNoteStatus(status, file); + } else { + await this.statusService.addNoteStatus(status, file); + } + } + } + private _openStatusDropdown(options: { target?: HTMLElement; position?: { x: number, y: number }; @@ -353,56 +395,80 @@ export class StatusDropdown { new Notice('No files selected'); return; } - + + // IMPORTANT: Always reset state at the beginning of each operation + // This ensures no lingering callbacks or files from previous operations + this.dropdownComponent.setTargetFile(null); + this.dropdownComponent.setOnStatusChange(() => {}); + // Determine if we're handling single or multiple files const isSingleFile = files.length === 1; const targetFile = isSingleFile ? files[0] : null; - + // Set target file (if single) or null (if multiple) this.dropdownComponent.setTargetFile(targetFile); - - // Get current statuses if single file, or reset to unknown for multiple - const currentStatuses = targetFile ? - this.statusService.getFileStatuses(targetFile) : - ['unknown']; - + + // Get current statuses appropriately + let currentStatuses: string[]; + + if (targetFile) { + // For single file, get its current statuses + currentStatuses = this.statusService.getFileStatuses(targetFile); + } else if (files.length > 1) { + // For multiple files, find common statuses to display + currentStatuses = this.findCommonStatuses(files); + } else { + currentStatuses = ['unknown']; + } + // Update dropdown with current statuses this.dropdownComponent.updateStatuses(currentStatuses); - + // Set custom callback for status changes if provided if (options.onStatusChange) { - const originalCallback = this.dropdownComponent.getOnStatusChange(); + // Use the provided callback directly this.dropdownComponent.setOnStatusChange(options.onStatusChange); - - // Restore original callback after operation - setTimeout(() => { - this.dropdownComponent.setOnStatusChange(originalCallback); - }, 300); - } else if (!isSingleFile && options.mode) { + } else if (!isSingleFile) { + // Create a local copy of files to avoid closure issues + const filesForBatch = [...files]; + // Set batch operation callback for multiple files + // Use toggle behavior for batch operations like single files this.dropdownComponent.setOnStatusChange(async (statuses) => { if (statuses.length > 0) { - await this.statusService.batchUpdateStatuses(files, statuses, options.mode || 'replace'); - + // Get the last selected status for toggling + const toggledStatus = statuses[statuses.length - 1]; + + // Toggle this status across all files + await this.toggleStatusForFiles(filesForBatch, toggledStatus); + // Dispatch events for UI update window.dispatchEvent(new CustomEvent('note-status:batch-update-complete', { detail: { - statuses, - fileCount: files.length, - mode: options.mode + status: toggledStatus, + fileCount: filesForBatch.length } })); window.dispatchEvent(new CustomEvent('note-status:refresh-ui')); } }); + } else { + // Default callback for standard operations + this.dropdownComponent.setOnStatusChange((statuses) => { + // Just emit events for UI updates + window.dispatchEvent(new CustomEvent('note-status:status-changed', { + detail: { statuses } + })); + window.dispatchEvent(new CustomEvent('note-status:refresh-ui')); + }); } - + // For dropdown from editor if (options.editor && options.view) { const position = this.getCursorPosition(options.editor, options.view); const dummyTarget = this.createDummyTarget(position); this.dropdownComponent.open(dummyTarget, position); - + // Clean up dummy target setTimeout(() => { if (dummyTarget.parentNode) { @@ -411,7 +477,7 @@ export class StatusDropdown { }, 100); return; } - + // For dropdown from toolbar button if (options.target) { if (options.position) { @@ -426,7 +492,7 @@ export class StatusDropdown { } return; } - + // For direct position (context menus) if (options.position) { const dummyTarget = document.createElement('div'); @@ -434,9 +500,9 @@ export class StatusDropdown { dummyTarget.style.setProperty('--pos-x-px', `${options.position.x}px`); dummyTarget.style.setProperty('--pos-y-px', `${options.position.y}px`); document.body.appendChild(dummyTarget); - + this.dropdownComponent.open(dummyTarget, options.position); - + // Clean up dummy target setTimeout(() => { if (dummyTarget.parentNode) { @@ -445,7 +511,7 @@ export class StatusDropdown { }, 100); return; } - + // Fallback to center position const center = { x: window.innerWidth / 2, @@ -456,9 +522,9 @@ export class StatusDropdown { fallbackTarget.style.setProperty('--pos-x-px', `${center.x}px`); fallbackTarget.style.setProperty('--pos-y-px', `${center.y}px`); document.body.appendChild(fallbackTarget); - + this.dropdownComponent.open(fallbackTarget, center); - + // Clean up fallback target setTimeout(() => { if (fallbackTarget.parentNode) { @@ -467,7 +533,6 @@ export class StatusDropdown { }, 100); } - /** * Adds the toolbar button to the active leaf */ @@ -514,5 +579,4 @@ export class StatusDropdown { toolbarContainer.appendChild(this.toolbarButton); } } - -} +} \ No newline at end of file diff --git a/ui/components/status-pane-view.ts b/ui/components/status-pane-view.ts index f2a07f1..2c98460 100644 --- a/ui/components/status-pane-view.ts +++ b/ui/components/status-pane-view.ts @@ -11,12 +11,22 @@ export class StatusPaneView extends View { searchInput: HTMLInputElement | null = null; private settings: NoteStatusSettings; private statusService: StatusService; + private paginationState: { + itemsPerPage: number; + currentPage: Record; + }; constructor(leaf: WorkspaceLeaf, plugin: NoteStatus) { super(leaf); this.plugin = plugin; this.settings = plugin.settings; this.statusService = plugin.statusService; + + // Initialize pagination + this.paginationState = { + itemsPerPage: 100, // Show 100 items per page by default + currentPage: {} // Track current page for each status + }; } getViewType(): string { @@ -33,7 +43,6 @@ export class StatusPaneView extends View { async onOpen(): Promise { await this.setupPane(); - await this.renderGroups(''); } async setupPane(): Promise { @@ -54,7 +63,17 @@ export class StatusPaneView extends View { containerEl.toggleClass('compact-view', this.settings.compactView); // Add a container for the groups - containerEl.createDiv({ cls: 'note-status-groups-container' }); + const groupsContainer = containerEl.createDiv({ cls: 'note-status-groups-container' }); + + // Add loading indicator + const loadingIndicator = groupsContainer.createDiv({ cls: 'note-status-loading' }); + loadingIndicator.innerHTML = 'Loading notes...'; + + // Use non-blocking render + setTimeout(async () => { + await this.renderGroups(''); + loadingIndicator.remove(); + }, 10); } private createSearchInput(container: HTMLElement): void { @@ -143,16 +162,89 @@ export class StatusPaneView extends View { const groupsContainer = containerEl.querySelector('.note-status-groups-container'); if (!groupsContainer) return; - groupsContainer.empty(); + // Show loading indicator for non-empty search queries + if (searchQuery) { + groupsContainer.empty(); + const loadingIndicator = groupsContainer.createDiv({ cls: 'note-status-loading' }); + loadingIndicator.innerHTML = `Searching for "${searchQuery}"...`; + + // Let the UI update before continuing + await new Promise(resolve => setTimeout(resolve, 0)); + } else { + groupsContainer.empty(); + } + + // Group files by status (with optimizations) + const statusGroups = this.getFilteredStatusGroups(searchQuery); + + // Remove the loading indicator if it exists + const loadingIndicator = groupsContainer.querySelector('.note-status-loading'); + if (loadingIndicator) { + loadingIndicator.remove(); + } - // Group files by status - const statusGroups = this.statusService.groupFilesByStatus(searchQuery); // Render each status group Object.entries(statusGroups).forEach(([status, files]) => { if (files.length > 0) { + // Skip unknown status if setting enabled + if (status === 'unknown' && this.settings.excludeUnknownStatus) { + return; + } this.renderStatusGroup(groupsContainer as HTMLElement, status, files); } }); + + // If no groups were rendered, show a message + if (groupsContainer.childElementCount === 0) { + const emptyMessage = groupsContainer.createDiv({ cls: 'note-status-empty-indicator' }); + if (searchQuery) { + emptyMessage.textContent = `No notes found matching "${searchQuery}"`; + } else if (this.settings.excludeUnknownStatus) { + // Clear any existing content and use a structured layout + emptyMessage.empty(); + + // Add message text in its own container + emptyMessage.createDiv({ + text: 'No notes with status found. Unassigned notes are currently hidden.', + cls: 'note-status-empty-message' + }); + + // Create separate container for the button + const btnContainer = emptyMessage.createDiv({ + cls: 'note-status-button-container' + }); + + // Add a styled button in its own container + const showUnknownBtn = btnContainer.createEl('button', { + text: 'Show unassigned notes', + cls: 'note-status-show-unassigned-button' + }); + + showUnknownBtn.addEventListener('click', async () => { + this.settings.excludeUnknownStatus = false; + await this.plugin.saveSettings(); + this.renderGroups(searchQuery); + }); + } + } + } + + /** + * Optimized method to get status groups with filtering + */ + private getFilteredStatusGroups(searchQuery = ''): Record { + // Use the statusService but apply our own filtering for better performance + const rawGroups = this.statusService.groupFilesByStatus(searchQuery); + const filteredGroups: Record = {}; + + // Filter out empty groups and respect the excludeUnknownStatus setting + Object.entries(rawGroups).forEach(([status, files]) => { + if (files.length > 0 && !(status === 'unknown' && this.settings.excludeUnknownStatus)) { + filteredGroups[status] = files; + } + }); + + return filteredGroups; } private renderStatusGroup(container: HTMLElement, status: string, files: TFile[]): void { @@ -187,13 +279,13 @@ export class StatusPaneView extends View { // Toggle the collapsed state if (isCurrentlyCollapsed) { - groupEl.removeClass('is-collapsed'); - collapseContainer.empty(); - setIcon(collapseContainer, 'chevron-down'); + groupEl.removeClass('is-collapsed'); + collapseContainer.empty(); + setIcon(collapseContainer, 'chevron-down'); } else { - groupEl.addClass('is-collapsed'); - collapseContainer.empty(); - setIcon(collapseContainer, 'chevron-right'); + groupEl.addClass('is-collapsed'); + collapseContainer.empty(); + setIcon(collapseContainer, 'chevron-right'); } // Update the settings @@ -208,14 +300,81 @@ export class StatusPaneView extends View { // Sort files by name files.sort((a, b) => a.basename.localeCompare(b.basename)); - - // Create file list items - files.forEach(file => { + + // Initialize pagination for this status if not already done + if (!this.paginationState.currentPage[status]) { + this.paginationState.currentPage[status] = 0; + } + + // Calculate pagination + const currentPage = this.paginationState.currentPage[status]; + const itemsPerPage = this.paginationState.itemsPerPage; + const totalPages = Math.ceil(files.length / itemsPerPage); + const startIndex = currentPage * itemsPerPage; + const endIndex = Math.min(startIndex + itemsPerPage, files.length); + + // Create file list items for current page only + const filesToRender = files.slice(startIndex, endIndex); + filesToRender.forEach(file => { this.createFileListItem(childrenEl, file, status); }); + + // Add pagination controls if needed + if (files.length > itemsPerPage) { + this.addPaginationControls(childrenEl, status, currentPage, totalPages, files.length); + } + } + + /** + * Add pagination controls to a group + */ + private addPaginationControls( + container: HTMLElement, + status: string, + currentPage: number, + totalPages: number, + totalItems: number + ): void { + const paginationEl = container.createDiv({ cls: 'note-status-pagination' }); + + // Add previous page button if not on first page + if (currentPage > 0) { + const prevButton = paginationEl.createEl('button', { + text: 'Previous', + cls: 'note-status-pagination-button' + }); + + prevButton.addEventListener('click', (e) => { + e.stopPropagation(); + this.paginationState.currentPage[status] = currentPage - 1; + this.renderGroups(this.searchInput?.value.toLowerCase() || ''); + }); + } + + // Add page indicator + paginationEl.createSpan({ + text: `Page ${currentPage + 1} of ${totalPages} (${totalItems} notes)`, + cls: 'note-status-pagination-info' + }); + + // Add next page button if not on last page + if (currentPage < totalPages - 1) { + const nextButton = paginationEl.createEl('button', { + text: 'Next', + cls: 'note-status-pagination-button' + }); + + nextButton.addEventListener('click', (e) => { + e.stopPropagation(); + this.paginationState.currentPage[status] = currentPage + 1; + this.renderGroups(this.searchInput?.value.toLowerCase() || ''); + }); + } } private createFileListItem(container: HTMLElement, file: TFile, status: string): void { + if (!file || !(file instanceof TFile)) return; // Skip if file is invalid + const fileEl = container.createDiv({ cls: 'nav-file' }); const fileTitleEl = fileEl.createDiv({ cls: 'nav-file-title' }); @@ -225,7 +384,7 @@ export class StatusPaneView extends View { setIcon(fileIcon, 'file'); } - // Add file name + // Add file name with proper class for styling fileTitleEl.createSpan({ text: file.basename, cls: 'nav-file-title-content' @@ -259,7 +418,6 @@ export class StatusPaneView extends View { .setIcon('tag') .onClick(() => { // Use the position from the event - // const position = { x: e.clientX, y: e.clientY }; this.plugin.statusContextMenu.showForFile(file, e); }) ); @@ -291,4 +449,4 @@ export class StatusPaneView extends View { // Refresh the view with the current search query this.renderGroups(this.searchInput?.value.toLowerCase() || ''); } -} +} \ No newline at end of file diff --git a/ui/integrations/explorer-integration.ts b/ui/integrations/explorer-integration.ts index df2b21b..58f0f8a 100644 --- a/ui/integrations/explorer-integration.ts +++ b/ui/integrations/explorer-integration.ts @@ -100,13 +100,26 @@ export class ExplorerIntegration { return; } - // Process files in the queue - for (const filePath of this.iconUpdateQueue) { - const file = this.app.vault.getFileByPath(filePath); - if (file && file instanceof TFile) { - this.updateSingleFileIcon(file, fileExplorerView); + // Process files in the queue in batches to avoid blocking UI + const batchSize = 50; + const allPaths = Array.from(this.iconUpdateQueue); + + for (let i = 0; i < allPaths.length; i += batchSize) { + const batch = allPaths.slice(i, i + batchSize); + + // Process this batch + for (const filePath of batch) { + const file = this.app.vault.getFileByPath(filePath); + if (file && file instanceof TFile) { + this.updateSingleFileIcon(file, fileExplorerView); + } + this.iconUpdateQueue.delete(filePath); + } + + // Let the UI breathe between batches + if (i + batchSize < allPaths.length) { + await new Promise(resolve => setTimeout(resolve, 0)); } - this.iconUpdateQueue.delete(filePath); } } catch (error) { console.error('Note Status: Error processing file update queue', error); @@ -122,34 +135,25 @@ export class ExplorerIntegration { /** * Update a single file's icon in the file explorer - */ + */ private updateSingleFileIcon(file: TFile, fileExplorerView: FileExplorerView): void { if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return; try { const fileItem = fileExplorerView.fileItems[file.path]; if (!fileItem) { - console.debug(`Note Status: File item not found for ${file.path}`); - return; + return; // File not in explorer view } const titleEl = fileItem.titleEl || fileItem.selfEl; if (!titleEl) { - console.debug(`Note Status: Title element not found for ${file.path}`); - return; + return; // No title element found } // Get statuses for this file - use fresh metadata cache - const freshFileCache = this.app.metadataCache.getFileCache(file); - if (!freshFileCache) { - console.debug(`Note Status: Metadata cache not found for ${file.path}`); - return; - } - - // Get statuses with fresh metadata const statuses = this.statusService.getFileStatuses(file); - // Remove existing icons if present + // Remove existing status icons if present (careful not to remove other elements) this.removeExistingIcons(titleEl); // Hide unknown status if setting is enabled @@ -157,6 +161,7 @@ export class ExplorerIntegration { return; } + // Add status icons WITHOUT disrupting the existing title this.addStatusIcons(titleEl, statuses); } catch (error) { @@ -177,18 +182,25 @@ export class ExplorerIntegration { * Remove existing status icons from an element */ private removeExistingIcons(element: HTMLElement): void { + // Only select our specific status icon elements const existingIcons = element.querySelectorAll('.note-status-icon, .note-status-icon-container'); - existingIcons.forEach(icon => icon.remove()); + + existingIcons.forEach(icon => { + // Only remove elements with our classes to avoid removing title or other elements + if (icon.classList.contains('note-status-icon') || + icon.classList.contains('note-status-icon-container')) { + icon.remove(); + } + }); } /** * Add status icons to a title element - */ + */ private addStatusIcons(titleEl: HTMLElement, statuses: string[]): void { - // Create container for multiple icons - const iconContainer = titleEl.createEl('span', { - cls: 'note-status-icon-container' - }); + // Create container for multiple icons - using createElement to avoid side effects + const iconContainer = document.createElement('span'); + iconContainer.className = 'note-status-icon-container'; // Add all status icons if (this.settings.useMultipleStatuses && statuses.length > 0 && statuses[0] !== 'unknown') { @@ -199,9 +211,10 @@ export class ExplorerIntegration { this.addPrimaryStatusIcon(iconContainer, statuses); } - // Remove container if empty (no icons added) - if (iconContainer.childElementCount === 0) { - iconContainer.remove(); + // Only append if we added icons + if (iconContainer.childElementCount > 0) { + // Use appendChild to add to the end instead of replacing content + titleEl.appendChild(iconContainer); } } @@ -228,23 +241,25 @@ export class ExplorerIntegration { * Add a single status icon */ private addSingleStatusIcon(container: HTMLElement, status: string): void { - const icon = this.statusService.getStatusIcon(status); - const iconEl = container.createEl('span', { - cls: `note-status-icon nav-file-tag status-${status}`, - text: icon - }); + // Create icon element instead of modifying container directly + const iconEl = document.createElement('span'); + iconEl.className = `note-status-icon nav-file-tag status-${status}`; + iconEl.textContent = this.statusService.getStatusIcon(status); // Add tooltip with status name const statusObj = this.statusService.getAllStatuses().find(s => s.name === status); const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}`: status; setTooltip(iconEl, tooltipValue); + + // Append to container + container.appendChild(iconEl); } /** * Update a single file's icon in the file explorer (public method) */ public updateFileExplorerIcons(file: TFile): void { - if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return; + if (!file || !this.settings.showStatusIconsInExplorer || file.extension !== 'md') return; // Add direct immediate update for critical files (like active file) const activeFile = this.app.workspace.getActiveFile(); @@ -264,29 +279,17 @@ export class ExplorerIntegration { */ private updateSingleFileIconDirectly(file: TFile): void { try { - const fileExplorer = this.app.workspace.getLeavesOfType('file-explorer')[0]; - if (!fileExplorer || !fileExplorer.view) return; + const fileExplorer = this.findFileExplorerView(); + if (!fileExplorer) return; - const fileExplorerView = fileExplorer.view as FileExplorerView; - if (!fileExplorerView.fileItems) return; - - const fileItem = fileExplorerView.fileItems[file.path]; - if (!fileItem) { - // If file item not found in current view, try to refresh all - setTimeout(() => this.updateAllFileExplorerIcons(), 50); - return; - } - - this.updateSingleFileIcon(file, fileExplorerView); + this.updateSingleFileIcon(file, fileExplorer); } catch (error) { console.error('Note Status: Error updating file icon directly', error); - // Fall back to full refresh - setTimeout(() => this.updateAllFileExplorerIcons(), 100); } } /** - * Update all file icons in the explorer + * Update all file icons in the explorer - with performance optimizations */ public updateAllFileExplorerIcons(): void { // Remove all icons if setting is turned off @@ -294,28 +297,40 @@ export class ExplorerIntegration { this.removeAllFileExplorerIcons(); return; } - - // Queue all markdown files for update - const files = this.app.vault.getMarkdownFiles(); - files.forEach(file => this.queueFileUpdate(file)); + + // Process files in batches + const processFilesInBatches = async () => { + const files = this.app.vault.getMarkdownFiles(); + const batchSize = 100; // Process 100 files at a time + + for (let i = 0; i < files.length; i += batchSize) { + const batch = files.slice(i, i + batchSize); + batch.forEach(file => this.queueFileUpdate(file)); + + // Let the UI breathe between batches + if (i + batchSize < files.length) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + }; + + // Start processing + processFilesInBatches(); } /** * Remove all status icons from the file explorer */ public removeAllFileExplorerIcons(): void { - const fileExplorer = this.app.workspace.getLeavesOfType('file-explorer')[0]; - if (!fileExplorer || !fileExplorer.view) return; + const fileExplorer = this.findFileExplorerView(); + if (!fileExplorer || !fileExplorer.fileItems) return; - const fileExplorerView = fileExplorer.view as FileExplorerView; - if (!fileExplorerView.fileItems) return; - - Object.values(fileExplorerView.fileItems).forEach((fileItem) => { + // Use Object.values to avoid issues with concurrent modification + Object.values(fileExplorer.fileItems).forEach((fileItem) => { const titleEl = fileItem.titleEl || fileItem.selfEl; if (!titleEl) return; - const existingIcons = titleEl.querySelectorAll('.note-status-icon, .note-status-icon-container'); - existingIcons.forEach(icon => icon.remove()); + this.removeExistingIcons(titleEl); }); // Clear the update queue @@ -326,15 +341,12 @@ export class ExplorerIntegration { * Get selected files from the file explorer */ public getSelectedFiles(): TFile[] { - const fileExplorer = this.app.workspace.getLeavesOfType('file-explorer')[0]; - if (!fileExplorer || !fileExplorer.view) return []; - - const fileExplorerView = fileExplorer.view as FileExplorerView; - if (!fileExplorerView.fileItems) return []; + const fileExplorer = this.findFileExplorerView(); + if (!fileExplorer || !fileExplorer.fileItems) return []; const selectedFiles: TFile[] = []; - Object.entries(fileExplorerView.fileItems).forEach(([_, item]) => { + Object.entries(fileExplorer.fileItems).forEach(([_, item]) => { if (item.el?.classList.contains('is-selected') && item.file && item.file instanceof TFile && item.file.extension === 'md') { diff --git a/ui/menus/status-context-menu.ts b/ui/menus/status-context-menu.ts index f7ce72a..ea3b7c2 100644 --- a/ui/menus/status-context-menu.ts +++ b/ui/menus/status-context-menu.ts @@ -65,7 +65,6 @@ export class StatusContextMenu { */ private showMultipleFilesMenu(files: TFile[], position?: { x: number; y: number }): void { const menu = new Menu(); - //menu.addClass('note-status-batch-menu'); // Add title section menu.addItem((item) => { @@ -74,35 +73,18 @@ export class StatusContextMenu { return item; }); - // Add "Replace with status" option + // Simplified toggle-based approach for multiple files menu.addItem((item) => item - .setTitle('Replace with status...') + .setTitle('Manage statuses...') .setIcon('tag') .onClick(() => { this.statusDropdown.openStatusDropdown({ position: position, - files: files, - mode: 'replace' + files: files }); }) ); - - // Add "Add status" option (only for multiple status mode) - if (this.settings.useMultipleStatuses) { - menu.addItem((item) => - item - .setTitle('Add status...') - .setIcon('plus') - .onClick(() => { - this.statusDropdown.openStatusDropdown({ - position: position, - files: files, - mode: 'add' - }); - }) - ); - } // Show the menu if (position) { @@ -192,4 +174,4 @@ export class StatusContextMenu { window.dispatchEvent(new CustomEvent('note-status:force-refresh')); }, 100); } -} +} \ No newline at end of file