mirror of
https://github.com/devonthesofa/obsidian-note-status.git
synced 2026-07-22 12:30:24 +00:00
Merge pull request #7 from devonthesofa/bugfix/performance-issues
[bugfix] Performance Optimizations for Large Vaults
This commit is contained in:
commit
df65537e0f
16 changed files with 912 additions and 163 deletions
38
README.md
38
README.md
|
|
@ -6,6 +6,16 @@ Enhance your Obsidian workflow with a powerful status tracking system for your n
|
|||
|
||||

|
||||
|
||||
## 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:
|
|||
|
||||

|
||||
|
||||
#### 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 
|
||||
3. Use pagination controls to navigate through large status groups 
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -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
BIN
images/pagination.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
images/show-unassigned-notes.png
Normal file
BIN
images/show-unassigned-notes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
82
main.ts
82
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<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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
34
scripts/README.md
Normal 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.
|
||||
249
scripts/generate-test-vault.ts
Normal file
249
scripts/generate-test-vault.ts
Normal 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');
|
||||
|
|
@ -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
|
||||
|
|
|
|||
135
styles.css
135
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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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() || '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue