Merge pull request #7 from devonthesofa/bugfix/performance-issues

[bugfix] Performance Optimizations for Large Vaults
This commit is contained in:
Aleix Soler 2025-05-09 21:54:16 +02:00 committed by GitHub
commit df65537e0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 912 additions and 163 deletions

View file

@ -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

View file

@ -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
};
/**

BIN
images/pagination.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

82
main.ts
View file

@ -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<void> {
// 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<void> {
public async openStatusPane(): Promise<void> {
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);

View file

@ -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
}
/**

View file

@ -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"
}
}

34
scripts/README.md Normal file
View file

@ -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.

View file

@ -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>', 'Number of notes to generate', '40000')
.option('-o, --output <path>', 'Output directory path', './test-vault')
.option('-t, --tag-prefix <string>', 'Status tag prefix', 'obsidian-note-status')
.option('-d, --depth <number>', 'Maximum folder depth', '5')
.option('-m, --max-per-folder <number>', '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<string>();
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');

View file

@ -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

View file

@ -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);
}

View file

@ -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);
});
}

View file

@ -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<void> {
// 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);
}
}
}
}

View file

@ -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<string, number>;
};
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<void> {
await this.setupPane();
await this.renderGroups('');
}
async setupPane(): Promise<void> {
@ -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 = '<span>Loading notes...</span>';
// 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 = `<span>Searching for "${searchQuery}"...</span>`;
// 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<string, TFile[]> {
// Use the statusService but apply our own filtering for better performance
const rawGroups = this.statusService.groupFilesByStatus(searchQuery);
const filteredGroups: Record<string, TFile[]> = {};
// 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() || '');
}
}
}

View file

@ -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') {

View file

@ -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);
}
}
}