refactor: simplified code

This commit is contained in:
Aleix Soler 2025-04-17 13:15:29 +02:00
parent 26f6f3e896
commit ad301b9543
16 changed files with 3401 additions and 3310 deletions

239
README.md
View file

@ -1,182 +1,141 @@
# Obsidian Note Status Plugin
# Note Status for Obsidian
![Version](https://img.shields.io/badge/version-1.0.8-blue) ![Obsidian](https://img.shields.io/badge/Obsidian-Compatible-green)
> Enhance your note organization with a powerful status management system for Obsidian
The **Note Status** plugin for Obsidian enhances your workflow by allowing you to assign, manage, and visualize statuses for your notes. Whether you're tracking projects, tasks, or personal notes, this plugin provides a seamless way to organize your vault with customizable statuses, a dedicated status pane, and visual indicators.
![GitHub release](https://img.shields.io/github/v/release/devonthesofa/obsidian-note-status)
![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=downloads&query=%24%5B%22note-status%22%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json)
![GitHub License](https://img.shields.io/github/license/devonthesofa/obsidian-note-status)
## Overview
The Note Status plugin allows you to assign and track statuses for your notes in Obsidian. Easily visualize where each note stands in your workflow using customizable statuses like active, on hold, completed, or dropped or create your own personalized status system.
<img src="https://raw.githubusercontent.com/devonthesofa/obsidian-note-status/main/resources/status-ui-preview.png" alt="Note Status UI Preview" width="800"/>
## Features
- **Status Assignment**: Add statuses (e.g., active, on hold, completed, dropped, unknown) to notes via frontmatter.
- **Status Pane**: A sidebar view to see all notes grouped by status, with search and collapsible sections.
- **Dropdown Menu**: Quickly change a note's status from a dropdown at the top or bottom of the editor.
- **Status Bar**: Displays the current note's status at the bottom of the app, with toggleable visibility.
- **File Explorer Icons**: Shows status icons next to note names in the file explorer for instant recognition.
- **Customization**: Define your own statuses, icons, and colors in the settings, and adjust UI preferences like position and auto-hiding.
- **Commands**: Includes commands to refresh statuses, insert status metadata, and open the status pane.
- **Assign Statuses to Notes**: Mark your notes with statuses to track their progress
- **File Explorer Integration**: See status icons directly in your file explorer
- **Status Bar & Dropdown**: Quick access to change status from the editor
- **Status Pane**: Dedicated view that organizes your notes by status
- **Batch Update**: Apply statuses to multiple files at once
- **Multiple Status Support**: Optionally apply multiple statuses to a single note
- **Template Support**: Choose from predefined status templates or create your own
- **Customizable**:
- Create custom statuses with icons and colors
- Set display preferences for UI elements
- Configure where and how status information appears
## Installation
1. **Download the Plugin**:
- Grab the latest release from the [GitHub Releases page](https://github.com/devonthesofa/obsidian-note-status/releases).
- Download the files `main.js`, `styles.css` and `manifest.json`
2. **Install in Obsidian**:
- Open your Obsidian vault and navigate to `.obsidian/plugins/`.
- Create a folder named `obsidian-note-status` into this directory.
- Copy the downloaded files in the `obsidian-note-status` folder.
3. **Enable the Plugin**:
- In Obsidian, go to Settings > Community Plugins.
- Ensure "Safe Mode" is turned off.
- Find "Note Status" in the list and toggle it on.
_Note_: This plugin is not yet available in the Obsidian Community Plugin store but will be submitted soon!
Install directly from Obsidian:
1. Open Settings → Community plugins
2. Disable Safe mode
3. Click "Browse" and search for "Note Status"
4. Click Install
5. Enable the plugin
## Usage
### Setting a Status
### Basic Usage
- Open a note and use the command Insert Status Metadata to add a status: unknown field in the frontmatter.
- Change the status manually in the frontmatter, or use the dropdown menu (click the status bar or right-click in the editor and select "Change Note Status").
1. Open any markdown note
2. Click on the status icon in the toolbar or use the status bar at the bottom
3. Select a status from the dropdown
4. The status is saved in your note's frontmatter
### Status Pane
- Click the ribbon icon (a bar chart) or use the Open Status Pane command to view all notes grouped by status.
- Use the search bar to filter notes by name.
Open the Status Pane via:
- Ribbon icon (left sidebar)
- Command palette: "Note Status: Open Status Pane"
### Customization
The Status Pane groups your notes by status for easy management and overview.
- Go to Settings > Note Status to:
- Toggle visibility of the dropdown, status bar, and file explorer icons.
- Adjust positions (top/bottom for dropdown, left/right for status bar).
- Add, edit, or remove custom statuses with unique icons and colors.
### Batch Updates
### Example Frontmatter
To update multiple files at once:
1. Select files in the file explorer
2. Right-click and choose "Change Status"
3. Or use the "Batch Update Status" command from the command palette
### Commands
- `Note Status: Open Status Pane` - Opens the status pane view
- `Note Status: Refresh Status` - Refreshes the current note's status
- `Note Status: Batch Update Status` - Opens the batch update modal
- `Note Status: Insert Status Metadata` - Inserts status metadata in the current note
- `Note Status: Toggle Status Dropdown` - Shows/hides the status dropdown
- `Note Status: Force Refresh UI` - Forces a complete UI refresh
## Configuration
Visit the plugin settings to customize:
- Default statuses and colors
- Enable/disable UI elements
- Choose status templates
- Create and manage custom statuses
- Configure display preferences
## Templates
The plugin includes several status templates:
- **Colorful Workflow**: A colorful set of workflow statuses with descriptive icons
- **Minimal Workflow**: A simplified set of essential workflow statuses
- **Academic Research**: Status workflow for academic research and writing
- **Project Management**: Status workflow for project management and tracking
## Advanced Usage
### Status Metadata Format
Statuses are stored in your note's frontmatter using the following format:
```yaml
---
status: active
obsidian-note-status: ["active"]
---
```
## Screenshots
For multiple statuses:
![image](https://github.com/user-attachments/assets/800eac9f-8bb9-4fe5-9da8-061fe0b8e7a2)
```yaml
---
obsidian-note-status: ["active", "inProgress"]
---
```
### Custom Hotkeys
Set up custom hotkeys for frequently used statuses in Obsidian's Hotkeys settings.
![Pasted image 20250403164128](https://github.com/user-attachments/assets/0a15a6c6-4630-4605-943b-a7528b4b42de)
## Contributing
![image](https://github.com/user-attachments/assets/3701c7ce-2a21-43ca-ad54-4b85756b4f6a)
- Status Pane: ![image](https://github.com/user-attachments/assets/e7bde491-546b-426f-808f-698bd8315f77)
- Dropdown Menu: ![image](https://github.com/user-attachments/assets/78d89124-04bc-4c13-952a-69fa74694261)
- File Explorer Icons: ![image](https://github.com/user-attachments/assets/a3068268-9067-4a41-80db-24922696aadc)
Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create your feature branch: `git checkout -b feature/my-new-feature`
3. Commit your changes: `git commit -am 'Add some feature'`
4. Push to the branch: `git push origin feature/my-new-feature`
5. Submit a pull request
## Development
### Project Structure
To build the plugin:
The plugin has been recently restructured with a modern, modular architecture:
```
note-status/
├── src/
│ ├── main.ts # Main plugin entry point
│ ├── constants/ # Constants and defaults
│ │ ├── icons.ts # SVG icon definitions
│ │ └── defaults.ts # Default settings and colors
│ ├── models/ # TypeScript interfaces and types
│ │ └── types.ts # Core type definitions
│ ├── ui/ # UI components
│ │ ├── status-pane-view.ts # Status pane sidebar
│ │ ├── status-dropdown.ts # Dropdown component
│ │ ├── status-bar.ts # Status bar component
│ │ ├── explorer.ts # File explorer integration
│ │ ├── modals.ts # Modal components
│ │ └── context-menus.ts # Context menu handlers
│ ├── services/ # Core services
│ │ ├── status-service.ts # Status management logic
│ │ └── style-service.ts # Dynamic styling service
│ ├── utils/ # Utility functions
│ │ ├── dom-utils.ts # DOM helpers
│ │ └── file-utils.ts # File-related helpers
│ └── settings/ # Settings UI
│ └── settings-tab.ts # Settings tab definition
└── styles.css # CSS styles
```
### Prerequisites
- Node.js and npm installed.
- Obsidian API knowledge (TypeScript-based).
### Building the Plugin
1. Clone this repository:
```bash
git clone https://github.com/devonthesofa/obsidian-note-status.git
cd obsidian-note-status
```
2. Install dependencies:
```bash
npm install
```
3. Build the plugin:
```bash
npm run build
```
4. The compiled plugin will be in the root directory, ready to copy into .obsidian/plugins/.
### Contributing
For development:
- Fork this repository and submit pull requests with improvements or bug fixes.
- Report issues or suggest features via the [Issues tab](https://github.com/devonthesofa/obsidian-note-status/issues).
## Recent Improvements
The v1.0.8 release includes:
- **Complete Code Restructuring**: Modular architecture with proper separation of concerns
- **Enhanced TypeScript Usage**: Improved typing and interfaces for better code reliability
- **Optimized Performance**: More efficient event handling and DOM manipulation
- **Improved Developer Experience**: Better project structure for easier contributions
## Known Issues (Being Addressed)
- **Bug:** When manually editing the status in the frontmatter **after** tags, a weird behavior occurs:
```
status: ...
---
tags:
- calendar/daily
---
It creates a new block of tags, leaving the old one outside. Needs refinement.
```
- **Improvement:** Further performance optimization for batch status changes
## About the Author
**Aleix Soler** is a professional developer who created this plugin for fun as his first contribution to the Obsidian ecosystem.
- **Website**: [aleixsoler.com](https://aleixsoler.com)
- **GitHub**: [@soler1212](https://github.com/soler1212)
- **Organization**: [@devonthesofa](https://github.com/devonthesofa)
As a daily Obsidian user, Aleix wanted to bring better status management to Obsidian to enhance his own workflow.
If you find this plugin helpful:
- ⭐ Star the repository
- 📣 Share with other Obsidian users
```bash
npm run dev
```
## License
This plugin is released under the MIT License. See the LICENSE file for details.
## Acknowledgments
- Built with assistance from Claude (Anthropic) for code structuring and best practices
- Inspired by the amazing Obsidian community and its plugin ecosystem
This project is licensed under the MIT License - see the LICENSE file for details.

1382
main.ts

File diff suppressed because it is too large Load diff

View file

@ -6,409 +6,471 @@ import { PREDEFINED_TEMPLATES } from '../constants/status-templates';
* Service for handling note status operations
*/
export class StatusService {
private app: App;
private settings: NoteStatusSettings;
private allStatuses: Status[] = [];
private app: App;
private settings: NoteStatusSettings;
private allStatuses: Status[] = [];
constructor(app: App, settings: NoteStatusSettings) {
this.app = app;
this.settings = settings;
this.updateAllStatuses();
}
constructor(app: App, settings: NoteStatusSettings) {
this.app = app;
this.settings = settings;
this.updateAllStatuses();
}
/**
* Updates the settings reference and recalculates all statuses
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.updateAllStatuses();
}
/**
* Updates the settings reference and recalculates all statuses
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.updateAllStatuses();
}
/**
* Updates the combined list of all statuses (from templates and custom)
*/
private updateAllStatuses(): void {
// Start with custom statuses if not using templates exclusively
this.allStatuses = [...this.settings.customStatuses];
/**
* Updates the combined list of all statuses (from templates and custom)
*/
private updateAllStatuses(): void {
// Start with custom statuses if not using templates exclusively
this.allStatuses = [...this.settings.customStatuses];
// Add statuses from enabled templates
if (!this.settings.useCustomStatusesOnly) {
const templateStatuses = this.getTemplateStatuses();
// Add template statuses that don't have the same name as existing statuses
for (const status of templateStatuses) {
if (!this.allStatuses.some(s => s.name.toLowerCase() === status.name.toLowerCase())) {
this.allStatuses.push(status);
} else {
// Update status colors if they come from a template and have colors
const existingStatusIndex = this.allStatuses.findIndex(
s => s.name.toLowerCase() === status.name.toLowerCase()
);
if (existingStatusIndex !== -1 && status.color) {
// Update color in settings if it doesn't exist
if (!this.settings.statusColors[status.name]) {
this.settings.statusColors[status.name] = status.color;
}
}
}
}
}
}
// Add statuses from enabled templates
if (!this.settings.useCustomStatusesOnly) {
const templateStatuses = this.getTemplateStatuses();
// Add template statuses that don't have the same name as existing statuses
for (const status of templateStatuses) {
if (!this.allStatuses.some(s => s.name.toLowerCase() === status.name.toLowerCase())) {
this.allStatuses.push(status);
} else {
this.updateExistingStatusColor(status);
}
}
}
}
/**
* Gets all statuses from enabled templates
*/
private getTemplateStatuses(): Status[] {
const statuses: Status[] = [];
// Find templates that are enabled
for (const templateId of this.settings.enabledTemplates) {
const template = PREDEFINED_TEMPLATES.find(t => t.id === templateId);
if (template) {
statuses.push(...template.statuses);
}
}
return statuses;
}
/**
* Update color for an existing status if it comes from a template
*/
private updateExistingStatusColor(status: Status): void {
// Update status colors if they come from a template and have colors
const existingStatusIndex = this.allStatuses.findIndex(
s => s.name.toLowerCase() === status.name.toLowerCase()
);
if (existingStatusIndex !== -1 && status.color) {
// Update color in settings if it doesn't exist
if (!this.settings.statusColors[status.name]) {
this.settings.statusColors[status.name] = status.color;
}
}
}
/**
* Get all available statuses (combined from templates and custom)
*/
public getAllStatuses(): Status[] {
return this.allStatuses;
}
/**
* Gets all statuses from enabled templates
*/
public getTemplateStatuses(): Status[] {
const statuses: Status[] = [];
// Find templates that are enabled
for (const templateId of this.settings.enabledTemplates) {
const template = PREDEFINED_TEMPLATES.find(t => t.id === templateId);
if (template) {
statuses.push(...template.statuses);
}
}
return statuses;
}
/**
* Get the statuses of a file from its metadata
* Returns an array of status names
*/
public getFileStatuses(file: TFile): string[] {
const cachedMetadata = this.app.metadataCache.getFileCache(file);
const statuses: string[] = [];
if (cachedMetadata?.frontmatter) {
// Check for status using the configured tag prefix
const frontmatterStatus = cachedMetadata.frontmatter[this.settings.tagPrefix];
if (frontmatterStatus !== undefined) {
if (Array.isArray(frontmatterStatus)) {
// Handle array format
for (const statusName of frontmatterStatus) {
const normalizedStatus = statusName.toString().toLowerCase();
const matchingStatus = this.allStatuses.find(s =>
s.name.toLowerCase() === normalizedStatus);
if (matchingStatus) {
statuses.push(matchingStatus.name);
}
}
} else {
// Handle single value format (string) - convert to array format internally
const normalizedStatus = frontmatterStatus.toString().toLowerCase();
const matchingStatus = this.allStatuses.find(s =>
s.name.toLowerCase() === normalizedStatus);
/**
* Get all available statuses (combined from templates and custom)
*/
public getAllStatuses(): Status[] {
return this.allStatuses;
}
if (matchingStatus) statuses.push(matchingStatus.name);
}
}
}
/**
* Get the statuses of a file from its metadata
* Returns an array of status names
*/
public getFileStatuses(file: TFile): string[] {
const cachedMetadata = this.app.metadataCache.getFileCache(file);
const statuses: string[] = [];
if (cachedMetadata?.frontmatter) {
// Check for status using the configured tag prefix
const frontmatterStatus = cachedMetadata.frontmatter[this.settings.tagPrefix];
if (frontmatterStatus !== undefined) {
if (Array.isArray(frontmatterStatus)) {
// Handle array format
this.processStatusArray(frontmatterStatus, statuses);
} else {
// Handle single value format (string) - convert to array format internally
this.processSingleStatus(frontmatterStatus.toString(), statuses);
}
}
}
// Return 'unknown' if no statuses found
return statuses.length > 0 ? statuses : ['unknown'];
}
// Return 'unknown' if no statuses found
return statuses.length > 0 ? statuses : ['unknown'];
}
/**
* Process an array of statuses from frontmatter
*/
private processStatusArray(statusArray: any[], targetStatuses: string[]): void {
for (const statusName of statusArray) {
const normalizedStatus = statusName.toString().toLowerCase();
const matchingStatus = this.allStatuses.find(s =>
s.name.toLowerCase() === normalizedStatus);
if (matchingStatus) {
targetStatuses.push(matchingStatus.name);
}
}
}
/**
* Process a single status string from frontmatter
*/
private processSingleStatus(statusString: string, targetStatuses: string[]): void {
const normalizedStatus = statusString.toLowerCase();
const matchingStatus = this.allStatuses.find(s =>
s.name.toLowerCase() === normalizedStatus);
/**
* Get the primary status of a file (first one, or 'unknown')
*/
public getFilePrimaryStatus(file: TFile): string {
const statuses = this.getFileStatuses(file);
return statuses[0] || 'unknown';
}
if (matchingStatus) targetStatuses.push(matchingStatus.name);
}
/**
* Get the icon for a given status
*/
public getStatusIcon(status: string): string {
const customStatus = this.allStatuses.find(
s => s.name.toLowerCase() === status.toLowerCase()
);
return customStatus ? customStatus.icon : '❓';
}
/**
* Get the primary status of a file (first one, or 'unknown')
*/
public getFilePrimaryStatus(file: TFile): string {
const statuses = this.getFileStatuses(file);
return statuses[0] || 'unknown';
}
/**
* Update the statuses of a note
* @param newStatuses Array of status names to set
* @param file Optional file to update, otherwise uses active file
*/
public async updateNoteStatuses(newStatuses: string[], file?: TFile): Promise<void> {
const targetFile = file || this.app.workspace.getActiveFile();
if (!targetFile || targetFile.extension !== 'md') return;
const content = await this.app.vault.read(targetFile);
// Create a new content with updated frontmatter
const newContent = this.updateFrontmatterWithStatus(content, newStatuses);
// Update the file only if content changed
if (newContent !== content) {
await this.app.vault.modify(targetFile, newContent);
// Force metadata cache refresh for this file (add this line)
this.app.metadataCache.trigger('changed', targetFile);
}
}
/**
* Get the icon for a given status
*/
public getStatusIcon(status: string): string {
const customStatus = this.allStatuses.find(
s => s.name.toLowerCase() === status.toLowerCase()
);
return customStatus ? customStatus.icon : '❓';
}
/**
* Update frontmatter content with new statuses
* This function properly handles different frontmatter formats
*/
private updateFrontmatterWithStatus(content: string, newStatuses: string[]): string {
// Check if frontmatter exists
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
if (frontmatterMatch) {
// Extract frontmatter
const fullFrontmatter = frontmatterMatch[0];
const frontmatterContent = frontmatterMatch[1];
// Format the new statuses as JSON array
const formattedArray = JSON.stringify(newStatuses);
// Create the new status line
const statusLine = `${this.settings.tagPrefix}: ${formattedArray}`;
// Create a regex to match the existing status tag and any following indented lines
const escapedPrefix = this.settings.tagPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const statusLineRegex = new RegExp(
`(^|\\n)${escapedPrefix}:.*?(\\n(?![ \\t]+[-])|$)`,
's'
);
// Check if the status tag already exists
if (frontmatterContent.match(statusLineRegex)) {
// Replace existing status tag
const updatedFrontmatter = frontmatterContent.replace(
statusLineRegex,
`$1${statusLine}$2`
);
// Replace the entire frontmatter section
return content.replace(
fullFrontmatter,
`---\n${updatedFrontmatter}\n---\n`
);
} else {
// Add new status tag to existing frontmatter
return content.replace(
fullFrontmatter,
`---\n${frontmatterContent}\n${statusLine}\n---\n`
);
}
} else {
// Create new frontmatter if none exists
const formattedArray = JSON.stringify(newStatuses);
return `---\n${this.settings.tagPrefix}: ${formattedArray}\n---\n\n${content.trim()}`;
}
}
/**
* Update the statuses of a note
* @param newStatuses Array of status names to set
* @param file Optional file to update, otherwise uses active file
*/
public async updateNoteStatuses(newStatuses: string[], file?: TFile): Promise<void> {
const targetFile = file || this.app.workspace.getActiveFile();
if (!targetFile || targetFile.extension !== 'md') return;
const content = await this.app.vault.read(targetFile);
// Create a new content with updated frontmatter
const newContent = this.updateFrontmatterWithStatus(content, newStatuses);
// Update the file only if content changed
if (newContent !== content) {
await this.app.vault.modify(targetFile, newContent);
// Force metadata cache refresh for this file
this.app.metadataCache.trigger('changed', targetFile);
}
}
/**
* Legacy method to update a single status for backward compatibility
*/
public async updateNoteStatus(newStatus: string, file?: TFile): Promise<void> {
// Always store as an array even in single status mode
await this.updateNoteStatuses([newStatus], file);
}
/**
* Update frontmatter content with new statuses
* This function properly handles different frontmatter formats
*/
private updateFrontmatterWithStatus(content: string, newStatuses: string[]): string {
// Check if frontmatter exists
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
if (frontmatterMatch) {
return this.updateExistingFrontmatter(content, frontmatterMatch, newStatuses);
} else {
return this.createNewFrontmatter(content, newStatuses);
}
}
/**
* Update existing frontmatter with new statuses
*/
private updateExistingFrontmatter(
content: string,
frontmatterMatch: RegExpMatchArray,
newStatuses: string[]
): string {
// Extract frontmatter
const fullFrontmatter = frontmatterMatch[0];
const frontmatterContent = frontmatterMatch[1];
// Format the new statuses as JSON array
const formattedArray = JSON.stringify(newStatuses);
// Create the new status line
const statusLine = `${this.settings.tagPrefix}: ${formattedArray}`;
// Create a regex to match the existing status tag and any following indented lines
const escapedPrefix = this.settings.tagPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const statusLineRegex = new RegExp(
`(^|\\n)${escapedPrefix}:.*?(\\n(?![ \\t]+[-])|$)`,
's'
);
// Check if the status tag already exists
if (frontmatterContent.match(statusLineRegex)) {
// Replace existing status tag
const updatedFrontmatter = frontmatterContent.replace(
statusLineRegex,
`$1${statusLine}$2`
);
// Replace the entire frontmatter section
return content.replace(
fullFrontmatter,
`---\n${updatedFrontmatter}\n---\n`
);
} else {
// Add new status tag to existing frontmatter
return content.replace(
fullFrontmatter,
`---\n${frontmatterContent}\n${statusLine}\n---\n`
);
}
}
/**
* Create new frontmatter for a file that doesn't have any
*/
private createNewFrontmatter(content: string, newStatuses: string[]): string {
const formattedArray = JSON.stringify(newStatuses);
return `---\n${this.settings.tagPrefix}: ${formattedArray}\n---\n\n${content.trim()}`;
}
/**
* Add a status to a note's existing statuses
*/
public async addNoteStatus(statusToAdd: string, file?: TFile): Promise<void> {
const targetFile = file || this.app.workspace.getActiveFile();
if (!targetFile || targetFile.extension !== 'md') return;
const currentStatuses = this.getFileStatuses(targetFile);
// Don't add if already exists
if (currentStatuses.includes(statusToAdd)) return;
// Filter out 'unknown' status when adding valid statuses
const filteredStatuses = currentStatuses.filter(s => s !== 'unknown');
const newStatuses = [...filteredStatuses, statusToAdd];
await this.updateNoteStatuses(newStatuses, targetFile);
// Add this line to trigger UI updates
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses: newStatuses }
}));
}
/**
* Legacy method to update a single status for backward compatibility
*/
public async updateNoteStatus(newStatus: string, file?: TFile): Promise<void> {
// Always store as an array even in single status mode
await this.updateNoteStatuses([newStatus], file);
}
/**
* Remove a status from a note's existing statuses
*/
public async removeNoteStatus(statusToRemove: string, file?: TFile): Promise<void> {
const targetFile = file || this.app.workspace.getActiveFile();
if (!targetFile || targetFile.extension !== 'md') return;
const currentStatuses = this.getFileStatuses(targetFile);
const newStatuses = currentStatuses.filter(status => status !== statusToRemove);
await this.updateNoteStatuses(newStatuses, targetFile);
// Add this line to trigger UI updates
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses: newStatuses }
}));
}
/**
* Add a status to a note's existing statuses
*/
public async addNoteStatus(statusToAdd: string, file?: TFile): Promise<void> {
const targetFile = file || this.app.workspace.getActiveFile();
if (!targetFile || targetFile.extension !== 'md') return;
const currentStatuses = this.getFileStatuses(targetFile);
// Don't add if already exists
if (currentStatuses.includes(statusToAdd)) return;
// Filter out 'unknown' status when adding valid statuses
const filteredStatuses = currentStatuses.filter(s => s !== 'unknown');
const newStatuses = [...filteredStatuses, statusToAdd];
await this.updateNoteStatuses(newStatuses, targetFile);
// Trigger UI updates
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses: newStatuses }
}));
}
/**
* Toggle a status on/off for a note
*/
public async toggleNoteStatus(statusToToggle: string, file?: TFile): Promise<void> {
const targetFile = file || this.app.workspace.getActiveFile();
if (!targetFile || targetFile.extension !== 'md') return;
const currentStatuses = this.getFileStatuses(targetFile);
let newStatuses: string[];
if (currentStatuses.includes(statusToToggle)) {
newStatuses = currentStatuses.filter(status => status !== statusToToggle);
} else {
// Filter out 'unknown' status when adding valid statuses
const filteredStatuses = currentStatuses.filter(s => s !== 'unknown');
newStatuses = [...filteredStatuses, statusToToggle];
}
await this.updateNoteStatuses(newStatuses, targetFile);
// Add this line to ensure UI updates
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses: newStatuses }
}));
}
/**
* Remove a status from a note's existing statuses
*/
public async removeNoteStatus(statusToRemove: string, file?: TFile): Promise<void> {
const targetFile = file || this.app.workspace.getActiveFile();
if (!targetFile || targetFile.extension !== 'md') return;
const currentStatuses = this.getFileStatuses(targetFile);
const newStatuses = currentStatuses.filter(status => status !== statusToRemove);
await this.updateNoteStatuses(newStatuses, targetFile);
// Trigger UI updates
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses: newStatuses }
}));
}
/**
* Batch update multiple files' statuses
* @param files Array of files to update
* @param statusesToSet Array of statuses to set
* @param mode 'replace' to replace all statuses, 'add' to add to existing
*/
public async batchUpdateStatuses(
files: TFile[],
statusesToSet: string[],
mode: 'replace' | 'add' = 'replace'
): Promise<void> {
if (files.length === 0) {
new Notice('No files selected');
return;
}
/**
* Toggle a status on/off for a note
*/
public async toggleNoteStatus(statusToToggle: string, file?: TFile): Promise<void> {
const targetFile = file || this.app.workspace.getActiveFile();
if (!targetFile || targetFile.extension !== 'md') return;
const currentStatuses = this.getFileStatuses(targetFile);
let newStatuses: string[];
if (currentStatuses.includes(statusToToggle)) {
newStatuses = currentStatuses.filter(status => status !== statusToToggle);
} else {
// Filter out 'unknown' status when adding valid statuses
const filteredStatuses = currentStatuses.filter(s => s !== 'unknown');
newStatuses = [...filteredStatuses, statusToToggle];
}
await this.updateNoteStatuses(newStatuses, targetFile);
// Ensure UI updates
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses: newStatuses }
}));
}
for (const file of files) {
if (mode === 'replace') {
await this.updateNoteStatuses(statusesToSet, file);
} else {
// Add each status
for (const status of statusesToSet) {
await this.addNoteStatus(status, file);
}
}
}
/**
* Batch update multiple files' statuses
* @param files Array of files to update
* @param statusesToSet Array of statuses to set
* @param mode 'replace' to replace all statuses, 'add' to add to existing
*/
public async batchUpdateStatuses(
files: TFile[],
statusesToSet: string[],
mode: 'replace' | 'add' = 'replace'
): Promise<void> {
if (files.length === 0) {
new Notice('No files selected');
return;
}
const statusText = statusesToSet.length === 1
? statusesToSet[0]
: `${statusesToSet.length} statuses`;
new Notice(`Updated ${files.length} file${files.length === 1 ? '' : 's'} with ${statusText}`);
}
for (const file of files) {
if (mode === 'replace') {
await this.updateNoteStatuses(statusesToSet, file);
} else {
// Add each status
for (const status of statusesToSet) {
await this.addNoteStatus(status, file);
}
}
}
/**
* Legacy batch update for a single status
*/
public async batchUpdateStatus(files: TFile[], newStatus: string): Promise<void> {
// Always store as array even in single status mode
await this.batchUpdateStatuses(files, [newStatus], 'replace');
}
const statusText = this.formatStatusText(statusesToSet);
new Notice(`Updated ${files.length} file${files.length === 1 ? '' : 's'} with ${statusText}`);
}
/**
* Format status text for notifications
*/
private formatStatusText(statusesToSet: string[]): string {
return statusesToSet.length === 1
? statusesToSet[0]
: `${statusesToSet.length} statuses`;
}
/**
* Insert status metadata in the editor
*/
public insertStatusMetadataInEditor(editor: Editor): void {
const content = editor.getValue();
const defaultStatuses = ['unknown'];
const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
/**
* Legacy batch update for a single status
*/
public async batchUpdateStatus(files: TFile[], newStatus: string): Promise<void> {
// Always store as array even in single status mode
await this.batchUpdateStatuses(files, [newStatus], 'replace');
}
// Check if frontmatter exists
const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/);
/**
* Insert status metadata in the editor
*/
public insertStatusMetadataInEditor(editor: Editor): void {
const content = editor.getValue();
const defaultStatuses = ['unknown'];
const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
if (frontMatterMatch) {
const frontMatter = frontMatterMatch[1];
let updatedFrontMatter = frontMatter;
// Check if frontmatter exists
const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/);
// Check if status tag already exists in frontmatter
const statusTagRegex = new RegExp(`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`, 'm');
if (frontMatter.match(statusTagRegex)) {
updatedFrontMatter = frontMatter.replace(statusTagRegex, statusMetadata);
} else {
updatedFrontMatter = `${frontMatter}\n${statusMetadata}`;
}
if (frontMatterMatch) {
this.insertIntoExistingFrontmatter(editor, content, frontMatterMatch, statusMetadata);
} else {
this.createFrontmatterWithStatus(editor, content, statusMetadata);
}
}
/**
* Insert status metadata into existing frontmatter
*/
private insertIntoExistingFrontmatter(
editor: Editor,
content: string,
frontMatterMatch: RegExpMatchArray,
statusMetadata: string
): void {
const frontMatter = frontMatterMatch[1];
let updatedFrontMatter = frontMatter;
const updatedContent = content.replace(/^---\n([\s\S]+?)\n---/, `---\n${updatedFrontMatter}\n---`);
editor.setValue(updatedContent);
} else {
// Create new frontmatter if it doesn't exist
const newFrontMatter = `---\n${statusMetadata}\n---\n${content}`;
editor.setValue(newFrontMatter);
}
}
// Check if status tag already exists in frontmatter
const statusTagRegex = new RegExp(`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`, 'm');
if (frontMatter.match(statusTagRegex)) {
updatedFrontMatter = frontMatter.replace(statusTagRegex, statusMetadata);
} else {
updatedFrontMatter = `${frontMatter}\n${statusMetadata}`;
}
/**
* Get all markdown files with optional filtering
*/
public getMarkdownFiles(searchQuery = ''): TFile[] {
const files = this.app.vault.getMarkdownFiles();
const updatedContent = content.replace(/^---\n([\s\S]+?)\n---/, `---\n${updatedFrontMatter}\n---`);
editor.setValue(updatedContent);
}
/**
* Create new frontmatter with status metadata
*/
private createFrontmatterWithStatus(editor: Editor, content: string, statusMetadata: string): void {
const newFrontMatter = `---\n${statusMetadata}\n---\n\n${content.trim()}`;
editor.setValue(newFrontMatter);
}
if (!searchQuery) {
return files;
}
/**
* Get all markdown files with optional filtering
*/
public getMarkdownFiles(searchQuery = ''): TFile[] {
const files = this.app.vault.getMarkdownFiles();
return files.filter(file =>
file.basename.toLowerCase().includes(searchQuery.toLowerCase())
);
}
if (!searchQuery) {
return files;
}
/**
* Group files by their status
*/
public groupFilesByStatus(searchQuery = ''): Record<string, TFile[]> {
const statusGroups: Record<string, TFile[]> = {};
return files.filter(file =>
file.basename.toLowerCase().includes(searchQuery.toLowerCase())
);
}
// Initialize groups for all statuses
this.allStatuses.forEach(status => {
statusGroups[status.name] = [];
});
/**
* Group files by their status
*/
public groupFilesByStatus(searchQuery = ''): Record<string, TFile[]> {
const statusGroups: Record<string, TFile[]> = {};
// Ensure 'unknown' status is included
if (!statusGroups['unknown']) {
statusGroups['unknown'] = [];
}
// Initialize groups for all statuses
this.allStatuses.forEach(status => {
statusGroups[status.name] = [];
});
// Get all markdown files and filter by search query
const files = this.getMarkdownFiles(searchQuery);
// Ensure 'unknown' status is included
if (!statusGroups['unknown']) {
statusGroups['unknown'] = [];
}
for (const file of files) {
const statuses = this.getFileStatuses(file);
// Add file to each of its status groups
for (const status of statuses) {
if (statusGroups[status]) {
statusGroups[status].push(file);
}
}
}
// Get all markdown files and filter by search query
const files = this.getMarkdownFiles(searchQuery);
return statusGroups;
}
for (const file of files) {
const statuses = this.getFileStatuses(file);
// Add file to each of its status groups
for (const status of statuses) {
if (statusGroups[status]) {
statusGroups[status].push(file);
}
}
}
return statusGroups;
}
}

View file

@ -1,310 +0,0 @@
import { TFile, WorkspaceLeaf, View, Menu, Notice } from 'obsidian';
import { NoteStatusSettings } from '../models/types';
import { StatusService } from '../services/status-service';
import { ICONS } from '../constants/icons';
/**
* Status Pane View for managing note statuses
*/
export class StatusPaneView extends View {
plugin: any;
searchInput: HTMLInputElement | null = null;
private settings: NoteStatusSettings;
private statusService: StatusService;
constructor(leaf: WorkspaceLeaf, plugin: any) {
super(leaf);
this.plugin = plugin;
this.settings = plugin.settings;
this.statusService = plugin.statusService;
}
getViewType(): string {
return 'status-pane';
}
getDisplayText(): string {
return 'Status Pane';
}
getIcon(): string {
return 'status-pane';
}
async onOpen(): Promise<void> {
await this.setupPane();
await this.renderGroups('');
}
async setupPane(): Promise<void> {
const { containerEl } = this;
containerEl.empty();
containerEl.addClass('note-status-pane', 'nav-files-container');
// Add a header container for better layout
const headerContainer = containerEl.createDiv({ cls: 'note-status-header' });
// Search container with search input
this.createSearchInput(headerContainer);
// Actions toolbar (view toggle, refresh)
this.createActionToolbar(headerContainer);
// Set initial compact view state
containerEl.toggleClass('compact-view', this.settings.compactView);
// Add a container for the groups
containerEl.createDiv({ cls: 'note-status-groups-container' });
}
private createSearchInput(container: HTMLElement): void {
const searchContainer = container.createDiv({ cls: 'note-status-search search-input-container' });
const searchWrapper = searchContainer.createDiv({ cls: 'search-input-wrapper' });
// Search icon
searchWrapper.createEl('span', { cls: 'search-input-icon' }).innerHTML = ICONS.search;
// Create the search input
this.searchInput = searchWrapper.createEl('input', {
type: 'text',
placeholder: 'Search notes...',
cls: 'note-status-search-input search-input'
});
// Add search event listener
this.searchInput.addEventListener('input', () => {
this.renderGroups(this.searchInput!.value.toLowerCase());
this.toggleClearButton();
});
// Clear search button (hidden by default)
const clearSearchBtn = searchWrapper.createEl('span', { cls: 'search-input-clear-button' });
clearSearchBtn.innerHTML = ICONS.clear;
clearSearchBtn.addEventListener('click', () => {
if (this.searchInput) {
this.searchInput.value = '';
this.renderGroups('');
this.toggleClearButton();
}
});
}
private toggleClearButton(): void {
const clearBtn = this.containerEl.querySelector('.search-input-clear-button');
if (clearBtn && this.searchInput) {
clearBtn.toggleClass('is-visible', !!this.searchInput.value);
}
}
private createActionToolbar(container: HTMLElement): void {
const actionsContainer = container.createDiv({ cls: 'status-pane-actions-container' });
// Toggle compact view button
const viewToggleButton = actionsContainer.createEl('button', {
type: 'button',
title: this.settings.compactView ? 'Switch to Standard View' : 'Switch to Compact View',
cls: 'note-status-view-toggle clickable-icon'
});
viewToggleButton.innerHTML = this.settings.compactView ? ICONS.standardView : ICONS.compactView;
viewToggleButton.addEventListener('click', async () => {
this.settings.compactView = !this.settings.compactView;
viewToggleButton.title = this.settings.compactView ? 'Switch to Standard View' : 'Switch to Compact View';
viewToggleButton.innerHTML = this.settings.compactView ? ICONS.standardView : ICONS.compactView;
// Trigger settings update
window.dispatchEvent(new CustomEvent('note-status:settings-changed'));
this.containerEl.toggleClass('compact-view', this.settings.compactView);
await this.renderGroups(this.searchInput?.value.toLowerCase() || '');
});
// Refresh button
const refreshButton = actionsContainer.createEl('button', {
type: 'button',
title: 'Refresh Statuses',
cls: 'note-status-actions-refresh clickable-icon'
});
refreshButton.innerHTML = ICONS.refresh;
refreshButton.addEventListener('click', async () => {
await this.renderGroups(this.searchInput?.value.toLowerCase() || '');
new Notice('Status pane refreshed');
});
}
async renderGroups(searchQuery = ''): Promise<void> {
const { containerEl } = this;
const groupsContainer = containerEl.querySelector('.note-status-groups-container');
if (!groupsContainer) return;
groupsContainer.empty();
// Group files by status
const statusGroups = this.statusService.groupFilesByStatus(searchQuery);
// Render each status group
Object.entries(statusGroups).forEach(([status, files]) => {
if (files.length > 0) {
this.renderStatusGroup(groupsContainer as HTMLElement, status, files);
}
});
}
private renderStatusGroup(container: HTMLElement, status: string, files: TFile[]): void {
const groupEl = container.createDiv({ cls: 'status-group nav-folder' });
const titleEl = groupEl.createDiv({ cls: 'nav-folder-title' });
// Create a container for the collapse button and title
const collapseContainer = titleEl.createDiv({ cls: 'collapse-indicator' });
collapseContainer.innerHTML = ICONS.collapseDown;
// Create a container for the title content
const titleContentContainer = titleEl.createDiv({ cls: 'nav-folder-title-content' });
const statusIcon = this.statusService.getStatusIcon(status);
titleContentContainer.createSpan({
text: `${status} ${statusIcon} (${files.length})`,
cls: `status-${status}`
});
// Handle collapsing/expanding behavior
titleEl.style.cursor = 'pointer';
const isCollapsed = this.settings.collapsedStatuses[status] ?? false;
if (isCollapsed) {
groupEl.addClass('is-collapsed');
collapseContainer.innerHTML = ICONS.collapseRight;
}
titleEl.addEventListener('click', (e) => {
e.preventDefault();
const isCurrentlyCollapsed = groupEl.hasClass('is-collapsed');
// Toggle the collapsed state
if (isCurrentlyCollapsed) {
groupEl.removeClass('is-collapsed');
collapseContainer.innerHTML = ICONS.collapseDown;
} else {
groupEl.addClass('is-collapsed');
collapseContainer.innerHTML = ICONS.collapseRight;
}
// Update the settings
this.settings.collapsedStatuses[status] = !isCurrentlyCollapsed;
// Trigger settings save
window.dispatchEvent(new CustomEvent('note-status:settings-changed'));
});
// Create and populate child elements
const childrenEl = groupEl.createDiv({ cls: 'nav-folder-children' });
// Sort files by name
files.sort((a, b) => a.basename.localeCompare(b.basename));
// Create file list items
files.forEach(file => {
this.createFileListItem(childrenEl, file, status);
});
}
private createFileListItem(container: HTMLElement, file: TFile, status: string): void {
const fileEl = container.createDiv({ cls: 'nav-file' });
const fileTitleEl = fileEl.createDiv({ cls: 'nav-file-title' });
// Add file icon if in standard view
if (!this.settings.compactView) {
const fileIcon = fileTitleEl.createDiv({ cls: 'nav-file-icon' });
fileIcon.innerHTML = ICONS.file;
}
// Add file name
fileTitleEl.createSpan({
text: file.basename,
cls: 'nav-file-title-content'
});
// Get all statuses for this file
const allStatuses = this.statusService.getFileStatuses(file);
// Show primary status icon by default
fileTitleEl.createSpan({
cls: `note-status-icon nav-file-tag status-${status}`,
text: this.statusService.getStatusIcon(status)
});
// If using multiple statuses and has multiple, show additional badges
if (this.settings.useMultipleStatuses && allStatuses.length > 1 && allStatuses[0] !== 'unknown') {
const additionalStatuses = allStatuses.filter(s => s !== status);
if (additionalStatuses.length > 0) {
const badgesContainer = fileTitleEl.createSpan({
cls: 'note-status-additional-badges'
});
additionalStatuses.forEach(additionalStatus => {
badgesContainer.createSpan({
cls: `note-status-mini-badge status-${additionalStatus}`,
text: this.statusService.getStatusIcon(additionalStatus)
});
});
}
}
// Add click handler to open the file
fileEl.addEventListener('click', (e) => {
e.preventDefault();
this.app.workspace.openLinkText(file.path, file.path, true);
});
// Add context menu
fileEl.addEventListener('contextmenu', (e) => {
e.preventDefault();
this.showFileContextMenu(e, file);
});
}
private showFileContextMenu(e: MouseEvent, file: TFile): void {
const menu = new Menu();
// Add status change options
menu.addItem((item) =>
item.setTitle('Change Status')
.setIcon('tag')
.onClick(() => {
this.plugin.showStatusContextMenu([file]);
})
);
// Add open options
menu.addItem((item) =>
item.setTitle('Open in New Tab')
.setIcon('lucide-external-link')
.onClick(() => {
this.app.workspace.openLinkText(file.path, file.path, 'tab');
})
);
menu.showAtMouseEvent(e);
}
onClose(): Promise<void> {
this.containerEl.empty();
return Promise.resolve();
}
/**
* Update view when settings change
*/
updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.containerEl.toggleClass('compact-view', settings.compactView);
// Refresh the view with the current search query
this.renderGroups(this.searchInput?.value.toLowerCase() || '');
}
}

View file

@ -1,6 +1,6 @@
import { Notice } from 'obsidian';
import { NoteStatusSettings } from '../models/types';
import { StatusService } from '../services/status-service';
import { NoteStatusSettings } from '../../models/types';
import { StatusService } from '../../services/status-service';
/**
* Handles the status bar functionality

View file

@ -0,0 +1,599 @@
import { setIcon, TFile } from 'obsidian';
import { NoteStatusSettings, Status } from '../../models/types';
import { StatusService } from '../../services/status-service';
/**
* Unified dropdown component for status selection
* This component handles the UI for selecting statuses across multiple contexts
*/
export class StatusDropdownComponent {
private app: any;
private statusService: StatusService;
private settings: NoteStatusSettings;
private dropdownElement: HTMLElement | null = null;
private currentStatuses: string[] = ['unknown'];
private onStatusChange: (statuses: string[]) => void;
private isOpen = false;
private animationDuration = 220;
private clickOutsideHandler: (e: MouseEvent) => void;
private targetFile: TFile | null = null;
constructor(app: any, statusService: StatusService, settings: NoteStatusSettings) {
this.app = app;
this.statusService = statusService;
this.settings = settings;
this.onStatusChange = () => {};
// Bind methods
this.handleClickOutside = this.handleClickOutside.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.clickOutsideHandler = this.handleClickOutside;
}
/**
* Updates the current statuses
*/
public updateStatuses(statuses: string[] | string): void {
// Normalize input to always be an array
if (typeof statuses === 'string') {
this.currentStatuses = [statuses];
} else {
this.currentStatuses = [...statuses]; // Create a copy to ensure it's updated
}
// Update dropdown if it's open
if (this.isOpen && this.dropdownElement) {
this.refreshDropdownContent();
}
}
/**
* Set the target file for status updates
*/
public setTargetFile(file: TFile | null): void {
this.targetFile = file;
}
/**
* Updates settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
// Update dropdown if it's open
if (this.isOpen && this.dropdownElement) {
this.refreshDropdownContent();
}
}
/**
* Set callback for status changes
*/
public setOnStatusChange(callback: (statuses: string[]) => void): void {
this.onStatusChange = callback;
}
/**
* Get the current status change callback
*/
public getOnStatusChange(): (statuses: string[]) => void {
return this.onStatusChange;
}
/**
* Toggle the dropdown visibility
*/
public toggle(targetEl: HTMLElement, position?: { x: number, y: number }): void {
if (this.isOpen) {
this.close();
} else {
this.open(targetEl, position);
}
}
/**
* Open the dropdown
*/
public open(targetEl: HTMLElement, position?: { x: number, y: number }): void {
if (this.isOpen) {
this.close();
}
this.isOpen = true;
// Create dropdown element
this.createDropdownElement();
// Fill dropdown content
this.refreshDropdownContent();
// Position the dropdown
if (position) {
this.positionAt(position.x, position.y);
} else {
this.positionRelativeTo(targetEl);
}
// Add animation class
this.dropdownElement?.addClass('note-status-popover-animate-in');
// Add event listeners
setTimeout(() => {
document.addEventListener('click', this.clickOutsideHandler);
document.addEventListener('keydown', this.handleEscapeKey);
}, 10);
}
/**
* Create the dropdown element
*/
private createDropdownElement(): void {
this.dropdownElement = document.createElement('div');
this.dropdownElement.addClass('note-status-popover', 'note-status-unified-dropdown');
document.body.appendChild(this.dropdownElement);
}
/**
* Close the dropdown
*/
public close(): void {
if (!this.dropdownElement || !this.isOpen) return;
// Add exit animation
this.dropdownElement.addClass('note-status-popover-animate-out');
// Clean up event listeners
document.removeEventListener('click', this.clickOutsideHandler);
document.removeEventListener('keydown', this.handleEscapeKey);
// Remove after animation completes
setTimeout(() => {
if (this.dropdownElement) {
this.dropdownElement.remove();
this.dropdownElement = null;
this.isOpen = false;
}
}, this.animationDuration);
}
/**
* Refresh the dropdown content
*/
private refreshDropdownContent(): void {
if (!this.dropdownElement) return;
// Clear content
this.dropdownElement.empty();
// Create header
this.createHeader();
// Create status chips section
this.createStatusChips();
// Create search filter
const searchInput = this.createSearchFilter();
// Create status options container
const statusOptionsContainer = this.dropdownElement.createDiv({
cls: 'note-status-options-container'
});
// Get all available statuses
const allStatuses = this.statusService.getAllStatuses()
.filter(status => status.name !== 'unknown');
// Create a function to populate options with filtering
const populateOptions = (filter = '') => {
this.populateStatusOptions(statusOptionsContainer, allStatuses, filter);
};
// Initial population
populateOptions();
// Add search functionality
searchInput.addEventListener('input', () => {
populateOptions(searchInput.value);
});
// Focus search input after a short delay
setTimeout(() => {
searchInput.focus();
}, 50);
}
/**
* Create the dropdown header
*/
private createHeader(): void {
if (!this.dropdownElement) return;
const headerEl = this.dropdownElement.createDiv({ cls: 'note-status-popover-header' });
// Title with icon
const titleEl = headerEl.createDiv({ cls: 'note-status-popover-title' });
const iconContainer = titleEl.createDiv({ cls: 'note-status-popover-icon' });
setIcon(iconContainer, 'tag');
titleEl.createSpan({ text: 'Note Status', cls: 'note-status-popover-label' });
}
/**
* Create the status chips section
*/
private createStatusChips(): void {
if (!this.dropdownElement) return;
const chipsContainer = this.dropdownElement.createDiv({ cls: 'note-status-popover-chips' });
// Show 'No status' indicator if no statuses or only unknown status
if (this.hasNoValidStatus()) {
this.createEmptyStatusIndicator(chipsContainer);
} else {
// Add chip for each status
this.createStatusChipElements(chipsContainer);
}
}
/**
* Check if there are no valid statuses
*/
private hasNoValidStatus(): boolean {
return this.currentStatuses.length === 0 ||
(this.currentStatuses.length === 1 && this.currentStatuses[0] === 'unknown');
}
/**
* Create empty status indicator
*/
private createEmptyStatusIndicator(container: HTMLElement): void {
container.createDiv({
cls: 'note-status-empty-indicator',
text: 'No status assigned'
});
}
/**
* Create chips for all current statuses
*/
private createStatusChipElements(container: HTMLElement): void {
this.currentStatuses.forEach(status => {
if (status === 'unknown') return; // Skip unknown status
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
if (!statusObj) return;
this.createSingleStatusChip(container, status, statusObj);
});
}
/**
* Create a single status chip
*/
private createSingleStatusChip(container: HTMLElement, status: string, statusObj: Status): void {
const chipEl = container.createDiv({
cls: `note-status-chip status-${status}`
});
// Status icon
chipEl.createSpan({
text: statusObj.icon,
cls: 'note-status-chip-icon'
});
// Status name
chipEl.createSpan({
text: statusObj.name,
cls: 'note-status-chip-text'
});
// Add remove button if multiple statuses allowed
if (this.settings.useMultipleStatuses && this.currentStatuses.length > 1) {
this.addRemoveButton(chipEl, status);
}
}
/**
* Add a remove button to a status chip
*/
private addRemoveButton(chipEl: HTMLElement, status: string): void {
const removeBtn = chipEl.createDiv({
cls: 'note-status-chip-remove',
attr: {
'aria-label': `Remove ${status} status`,
'title': `Remove ${status} status`
}
});
setIcon(removeBtn, 'x');
removeBtn.addEventListener('click', async (e) => {
e.stopPropagation();
// 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 () => {
await this.removeStatus(status);
}, 150);
}
});
}
/**
* Remove a status from the target file
*/
private async removeStatus(status: string): Promise<void> {
if (!this.targetFile) return;
// Remove this status
await this.statusService.removeNoteStatus(status, this.targetFile);
// Get updated statuses
const updatedStatuses = this.statusService.getFileStatuses(this.targetFile);
// Update current statuses
this.currentStatuses = updatedStatuses;
// Refresh chips
this.refreshDropdownContent();
// Call status change callback
this.onStatusChange(updatedStatuses);
}
/**
* Create the search filter input
*/
private createSearchFilter(): HTMLInputElement {
if (!this.dropdownElement) {
throw new Error("Dropdown element not initialized");
}
const searchContainer = this.dropdownElement.createDiv({ cls: 'note-status-popover-search' });
const searchInput = searchContainer.createEl('input', {
type: 'text',
placeholder: 'Filter statuses...',
cls: 'note-status-popover-search-input'
});
return searchInput;
}
/**
* Populate status options with optional filtering
*/
private populateStatusOptions(
container: HTMLElement,
statuses: Status[],
filter = ''
): void {
container.empty();
const filteredStatuses = this.filterStatuses(statuses, filter);
if (filteredStatuses.length === 0) {
this.createNoMatchingStatusesMessage(container, filter);
return;
}
filteredStatuses.forEach(status => {
this.createStatusOption(container, status);
});
}
/**
* Filter statuses based on search term
*/
private filterStatuses(statuses: Status[], filter: string): Status[] {
if (!filter) return statuses;
return statuses.filter(status =>
status.name.toLowerCase().includes(filter.toLowerCase()) ||
status.icon.includes(filter)
);
}
/**
* Create a message for no matching statuses
*/
private createNoMatchingStatusesMessage(container: HTMLElement, filter: string): void {
container.createDiv({
cls: 'note-status-empty-options',
text: filter ? `No statuses match "${filter}"` : 'No statuses found'
});
}
/**
* Create a single status option element
*/
private createStatusOption(container: HTMLElement, status: Status): void {
const isSelected = this.currentStatuses.includes(status.name);
const optionEl = container.createDiv({
cls: `note-status-option ${isSelected ? 'is-selected' : ''} status-${status.name}`
});
// Status icon
optionEl.createSpan({
text: status.icon,
cls: 'note-status-option-icon'
});
// Status name
optionEl.createSpan({
text: status.name,
cls: 'note-status-option-text'
});
// Check icon for selected status
if (isSelected) {
const checkIcon = optionEl.createDiv({ cls: 'note-status-option-check' });
setIcon(checkIcon, 'check');
}
// Add click handler
optionEl.addEventListener('click', () => this.handleStatusOptionClick(optionEl, status));
}
/**
* Handle click on a status option
*/
private handleStatusOptionClick(optionEl: HTMLElement, status: Status): void {
try {
// Add selection animation
optionEl.addClass('note-status-option-selecting');
// Apply status changes after brief delay for animation
setTimeout(async () => {
if (this.targetFile) {
await this.handleStatusChangeForTargetFile(status);
} else {
// This is for batch operations or when no specific file is targeted
this.onStatusChange([status.name]);
// Usually we want to close after selection in this case
if (!this.settings.useMultipleStatuses) {
this.close();
}
}
}, 150);
} catch (error) {
console.error('Error updating status:', error);
}
}
/**
* Handle status change for a specific target file
*/
private async handleStatusChangeForTargetFile(status: Status): Promise<void> {
if (!this.targetFile) return;
if (this.settings.useMultipleStatuses) {
await this.statusService.toggleNoteStatus(status.name, this.targetFile);
} else {
await this.statusService.updateNoteStatuses([status.name], this.targetFile);
// Close dropdown in single status mode
this.close();
}
// Get fresh status from file
const freshStatuses = this.statusService.getFileStatuses(this.targetFile);
// Update current statuses
this.currentStatuses = [...freshStatuses];
// Call status change callback
this.onStatusChange(freshStatuses);
}
/**
* Position the dropdown at specific coordinates
*/
private positionAt(x: number, y: number): void {
if (!this.dropdownElement) return;
this.dropdownElement.style.position = 'fixed';
this.dropdownElement.style.zIndex = '999';
this.dropdownElement.style.left = `${x}px`;
this.dropdownElement.style.top = `${y}px`;
// Ensure dropdown doesn't go off-screen
setTimeout(() => this.adjustPositionToViewport(), 0);
}
/**
* Adjust the dropdown position to ensure it's visible in the viewport
*/
private adjustPositionToViewport(): void {
if (!this.dropdownElement) return;
const rect = this.dropdownElement.getBoundingClientRect();
if (rect.right > window.innerWidth) {
this.dropdownElement.style.left = `${window.innerWidth - rect.width - 10}px`;
}
if (rect.bottom > window.innerHeight) {
this.dropdownElement.style.top = `${window.innerHeight - rect.height - 10}px`;
}
// Set max height based on viewport
const maxHeight = window.innerHeight - rect.top - 20;
this.dropdownElement.style.maxHeight = `${maxHeight}px`;
}
/**
* Position the dropdown relative to a target element
*/
private positionRelativeTo(targetEl: HTMLElement): void {
if (!this.dropdownElement) return;
// Reset positioning
this.dropdownElement.style.position = 'fixed';
this.dropdownElement.style.zIndex = '999';
// Get target element's position
const targetRect = targetEl.getBoundingClientRect();
// Position below the element
this.dropdownElement.style.top = `${targetRect.bottom + 5}px`;
// Align to left edge of target by default
this.dropdownElement.style.left = `${targetRect.left}px`;
// Check if dropdown would go off-screen
setTimeout(() => this.adjustRelativePosition(targetRect), 0);
}
/**
* Adjust position when positioned relative to an element
*/
private adjustRelativePosition(targetRect: DOMRect): void {
if (!this.dropdownElement) return;
const rect = this.dropdownElement.getBoundingClientRect();
if (rect.right > window.innerWidth) {
// Align to right edge instead
this.dropdownElement.style.left = 'auto';
this.dropdownElement.style.right = `${window.innerWidth - targetRect.right}px`;
}
if (rect.bottom > window.innerHeight) {
// Position above the element instead
this.dropdownElement.style.top = 'auto';
this.dropdownElement.style.bottom = `${window.innerHeight - targetRect.top + 5}px`;
}
// Set max height based on viewport
const maxHeight = window.innerHeight - rect.top - 20;
this.dropdownElement.style.maxHeight = `${maxHeight}px`;
}
/**
* Handle click outside the dropdown
*/
private handleClickOutside(e: MouseEvent): void {
if (this.dropdownElement && !this.dropdownElement.contains(e.target as Node)) {
this.close();
}
}
/**
* Handle escape key to close dropdown
*/
private handleEscapeKey(e: KeyboardEvent): void {
if (e.key === 'Escape') {
this.close();
}
}
/**
* Dispose of resources
*/
public dispose(): void {
this.close();
}
}

View file

@ -0,0 +1,342 @@
import { MarkdownView, Editor } from 'obsidian';
import { NoteStatusSettings } from '../../models/types';
import { StatusService } from '../../services/status-service';
import { StatusDropdownComponent } from './status-dropdown-component';
/**
* Enhanced status dropdown with toolbar integration
*/
export class StatusDropdown {
private app: any;
private settings: NoteStatusSettings;
private statusService: StatusService;
private currentStatuses: string[] = ['unknown'];
private toolbarButtonContainer?: HTMLElement;
private toolbarButton?: HTMLElement;
private dropdownComponent: StatusDropdownComponent;
constructor(app: any, settings: NoteStatusSettings, statusService: StatusService) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
// Initialize the dropdown component
this.dropdownComponent = new StatusDropdownComponent(app, statusService, settings);
// Configure dropdown component callbacks
this.setupDropdownCallbacks();
// Initialize toolbar button after layout is ready
this.app.workspace.onLayoutReady(() => {
this.initToolbarButton();
});
}
/**
* Set up the dropdown callbacks
*/
private setupDropdownCallbacks(): void {
this.dropdownComponent.setOnStatusChange((statuses) => {
// Update current statuses and toolbar button
this.currentStatuses = [...statuses]; // Make sure to copy the array
this.updateToolbarButton();
// Dispatch events for UI update
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses }
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
});
}
/**
* Initialize the toolbar button in the Obsidian ribbon
*/
private initToolbarButton(): void {
// Clear any existing button
if (this.toolbarButton) {
this.toolbarButton.remove();
this.toolbarButton = undefined;
}
if (this.toolbarButtonContainer) {
this.toolbarButtonContainer.remove();
this.toolbarButtonContainer = undefined;
}
// Wait for next tick to ensure the UI is fully rendered
setTimeout(() => {
const toolbarContainer = this.findToolbarContainer();
if (!toolbarContainer) {
console.error('Note Status: Could not find toolbar container');
return;
}
this.createToolbarButton(toolbarContainer);
}, 500); // Waiting 500ms to ensure the UI is ready
}
/**
* Find the toolbar container element
*/
private findToolbarContainer(): HTMLElement | null {
return document.querySelector('.view-header-actions') ||
document.querySelector('.view-actions') ||
document.querySelector('.workspace-ribbon.mod-right');
}
/**
* Create the toolbar button element
*/
private createToolbarButton(toolbarContainer: HTMLElement): void {
// Create button container for proper positioning
this.toolbarButtonContainer = document.createElement('div');
this.toolbarButtonContainer.addClass('note-status-toolbar-button-container');
// Create the button element
this.toolbarButton = document.createElement('button');
this.toolbarButton.addClass('note-status-toolbar-button', 'clickable-icon');
this.toolbarButton.setAttribute('aria-label', 'Note Status');
// Update initial button state
this.updateToolbarButton();
// Add click handler
this.toolbarButton.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
this.toggleStatusPopover();
});
// Add the button to the container
this.toolbarButtonContainer.appendChild(this.toolbarButton);
// Insert at a more reliable position - just prepend to the container
try {
toolbarContainer.prepend(this.toolbarButtonContainer);
} catch (error) {
console.error('Note Status: Error inserting toolbar button', error);
// Fallback - just append it
toolbarContainer.appendChild(this.toolbarButtonContainer);
}
}
/**
* Updates the toolbar button appearance based on current statuses
*/
private updateToolbarButton(): void {
if (!this.toolbarButton) return;
// Clear existing content
this.toolbarButton.empty();
// Check if we have a valid status
const hasValidStatus = this.currentStatuses.length > 0 &&
!this.currentStatuses.every(status => status === 'unknown');
// Create badge container
const badgeContainer = document.createElement('div');
badgeContainer.addClass('note-status-toolbar-badge-container');
if (hasValidStatus) {
this.addStatusBadge(badgeContainer);
} else {
this.addDefaultStatusIcon(badgeContainer);
}
this.toolbarButton.appendChild(badgeContainer);
}
/**
* Add a status badge to the toolbar button
*/
private addStatusBadge(container: HTMLElement): void {
// Show primary status icon and indicator for multiple statuses
const primaryStatus = this.currentStatuses[0];
const statusInfo = this.statusService.getAllStatuses().find(s => s.name === primaryStatus);
if (statusInfo) {
// Primary status icon
const iconSpan = document.createElement('span');
iconSpan.addClass(`note-status-toolbar-icon`, `status-${primaryStatus}`);
iconSpan.textContent = statusInfo.icon;
container.appendChild(iconSpan);
// Add count indicator if multiple statuses
if (this.settings.useMultipleStatuses && this.currentStatuses.length > 1) {
const countBadge = document.createElement('span');
countBadge.addClass('note-status-count-badge');
countBadge.textContent = `+${this.currentStatuses.length - 1}`;
container.appendChild(countBadge);
}
}
}
/**
* Add default status icon when no valid status
*/
private addDefaultStatusIcon(container: HTMLElement): void {
const iconSpan = document.createElement('span');
iconSpan.addClass('note-status-toolbar-icon', 'status-unknown');
iconSpan.textContent = '📌'; // Default tag icon
container.appendChild(iconSpan);
}
/**
* Updates the dropdown UI based on current statuses
*/
public update(currentStatuses: string[] | string): void {
// Normalize input to always be an array
if (typeof currentStatuses === 'string') {
this.currentStatuses = [currentStatuses];
} else {
this.currentStatuses = [...currentStatuses]; // Create a copy to ensure it's updated
}
// Update toolbar button
this.updateToolbarButton();
// Update dropdown component
this.dropdownComponent.updateStatuses(this.currentStatuses);
}
/**
* Updates settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.updateToolbarButton();
this.dropdownComponent.updateSettings(settings);
}
/**
* Toggle the status popover
*/
private toggleStatusPopover(): void {
// Get the active file first
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
// Set the target file for the dropdown component
this.dropdownComponent.setTargetFile(activeFile);
// Get current statuses
const currentStatuses = this.statusService.getFileStatuses(activeFile);
this.dropdownComponent.updateStatuses(currentStatuses);
// Create a position below the toolbar button
if (this.toolbarButton) {
const rect = this.toolbarButton.getBoundingClientRect();
const position = {
x: rect.left,
y: rect.bottom + 5
};
// Force open the dropdown with explicit position
this.dropdownComponent.open(this.toolbarButton, position);
}
}
/**
* Show status dropdown in context menu
*/
public showInContextMenu(editor: Editor, view: MarkdownView): void {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
// Get cursor position or fallback to other locations
const position = this.getCursorPosition(editor, view);
// Create a dummy target element for positioning
const dummyTarget = this.createDummyTarget(position);
// Set the active file as the target for the dropdown
this.dropdownComponent.setTargetFile(activeFile);
// Get current statuses for this file
const currentStatuses = this.statusService.getFileStatuses(activeFile);
// Update dropdown with current statuses
this.dropdownComponent.updateStatuses(currentStatuses);
// Show dropdown at the calculated position
this.dropdownComponent.open(dummyTarget, position);
// Clean up dummy target after dropdown is shown
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
}, 100);
}
/**
* Get position from cursor or fallback positions
*/
private getCursorPosition(editor: Editor, view: MarkdownView): {x: number, y: number} {
try {
// Try to get cursor position
const cursor = editor.getCursor('head');
const editorPosition = editor.posToCoords(cursor);
if (editorPosition) {
return {
x: editorPosition.left,
y: editorPosition.top + 20 // Add small offset below cursor
};
}
// Fallback to editor element position
const editorEl = view.contentEl.querySelector('.cm-editor');
if (editorEl) {
const rect = editorEl.getBoundingClientRect();
return {
x: rect.left + 100, // Offset from left
y: rect.top + 100 // Offset from top
};
}
} catch (error) {
console.error('Error getting position for dropdown:', error);
}
// Last resort - use middle of viewport
return {
x: window.innerWidth / 2,
y: window.innerHeight / 3
};
}
/**
* Create a dummy target element for positioning
*/
private createDummyTarget(position: {x: number, y: number}): HTMLElement {
const dummyTarget = document.createElement('div');
dummyTarget.style.position = 'fixed';
dummyTarget.style.left = `${position.x}px`;
dummyTarget.style.top = `${position.y}px`;
dummyTarget.style.zIndex = '1000';
document.body.appendChild(dummyTarget);
return dummyTarget;
}
/**
* Render method (kept for compatibility)
*/
public render(): void {
// This is now a no-op, as the dropdown component handles everything internally
}
/**
* Remove dropdown when plugin is unloaded
*/
public unload(): void {
// Clean up dropdown component
this.dropdownComponent.dispose();
// Remove toolbar button
if (this.toolbarButtonContainer) {
this.toolbarButtonContainer.remove();
this.toolbarButtonContainer = undefined;
}
}
}

View file

@ -1,7 +1,7 @@
import { TFile, WorkspaceLeaf, View, Menu, Notice } from 'obsidian';
import { NoteStatusSettings } from '../models/types';
import { StatusService } from '../services/status-service';
import { ICONS } from '../constants/icons';
import { NoteStatusSettings } from '../../models/types';
import { StatusService } from '../../services/status-service';
import { ICONS } from '../../constants/icons';
/**
* Status Pane View for managing note statuses
@ -256,7 +256,7 @@ export class StatusPaneView extends View {
.setIcon('tag')
.onClick(() => {
// Use the position from the event
const position = { x: e.clientX, y: e.clientY };
// const position = { x: e.clientX, y: e.clientY };
this.plugin.statusContextMenu.showForFile(file, e);
})
);

View file

@ -1,322 +0,0 @@
import { App, Menu, TFile } from 'obsidian';
import { NoteStatusSettings } from '../models/types';
import { StatusService } from '../services/status-service';
import { StatusDropdownComponent } from './status-dropdown-component';
/**
* Handles context menu interactions for status changes
*/
export class StatusContextMenu {
private settings: NoteStatusSettings;
private statusService: StatusService;
public app: App;
private dropdownComponent: StatusDropdownComponent;
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.dropdownComponent = new StatusDropdownComponent(app, statusService, settings);
// Set up the callback to handle status changes
this.dropdownComponent.setOnStatusChange((statuses) => {
// Notify UI of status changes
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses }
}));
// Force a full UI refresh to ensure all elements are updated
window.dispatchEvent(new CustomEvent('note-status:force-refresh'));
// Also trigger explorer update specifically
setTimeout(() => {
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}, 50);
});
}
/**
* Updates settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.dropdownComponent.updateSettings(settings);
}
/**
* Shows the context menu for changing a status of one or more files
*/
public showForFiles(files: TFile[], position?: { x: number; y: number }): void {
if (files.length === 0) return;
// For single file, we can show the dropdown directly without menu
if (files.length === 1) {
const targetFile = files[0];
// Get the current statuses for this file
const currentStatuses = this.statusService.getFileStatuses(targetFile);
// Create a dummy target element for dropdown positioning
const dummyTarget = document.createElement('div');
if (position) {
dummyTarget.style.position = 'fixed';
dummyTarget.style.left = `${position.x}px`;
dummyTarget.style.top = `${position.y}px`;
dummyTarget.style.width = '0';
dummyTarget.style.height = '0';
document.body.appendChild(dummyTarget);
}
// Set the target file for the dropdown to update
this.dropdownComponent.setTargetFile(targetFile);
// Update dropdown with current statuses
this.dropdownComponent.updateStatuses(currentStatuses);
// Show the dropdown
if (position) {
this.dropdownComponent.open(dummyTarget, position);
} else {
// If no position, use screen center
const center = {
x: window.innerWidth / 2,
y: window.innerHeight / 2
};
this.dropdownComponent.open(dummyTarget, center);
}
// Clean up dummy target after dropdown is shown
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
}, 100);
return;
}
// For multiple files, show a menu first with options for batch actions
const menu = new Menu();
menu.addClass('note-status-batch-menu');
// Add title section
menu.addItem((item) => {
item.setTitle(`Update ${files.length} files`)
.setDisabled(true);
return item;
});
// Add "Replace with status" option
menu.addItem((item) =>
item
.setTitle('Replace with status...')
.setIcon('tag')
.onClick(() => {
// Create dummy element for positioning
const dummyTarget = document.createElement('div');
if (position) {
dummyTarget.style.position = 'fixed';
dummyTarget.style.left = `${position.x}px`;
dummyTarget.style.top = `${position.y}px`;
document.body.appendChild(dummyTarget);
}
// Clear target file since we're handling multiple
this.dropdownComponent.setTargetFile(null);
// Set up callback for multiple files
this.dropdownComponent.setOnStatusChange(async (statuses) => {
if (statuses.length > 0) {
// Batch update all files with the selected statuses
await this.statusService.batchUpdateStatuses(files, statuses, 'replace');
// Dispatch events for UI update
window.dispatchEvent(new CustomEvent('note-status:batch-update-complete', {
detail: {
statuses,
fileCount: files.length,
mode: 'replace'
}
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}
});
// Reset to unknown status for selection
this.dropdownComponent.updateStatuses(['unknown']);
// Show the dropdown
if (position) {
this.dropdownComponent.open(dummyTarget, position);
} else {
const center = {
x: window.innerWidth / 2,
y: window.innerHeight / 2
};
this.dropdownComponent.open(dummyTarget, center);
}
// Clean up dummy target
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
}, 100);
})
);
// Add "Add status" option (only for multiple status mode)
if (this.settings.useMultipleStatuses) {
menu.addItem((item) =>
item
.setTitle('Add status...')
.setIcon('plus')
.onClick(() => {
// Create dummy element for positioning
const dummyTarget = document.createElement('div');
if (position) {
dummyTarget.style.position = 'fixed';
dummyTarget.style.left = `${position.x}px`;
dummyTarget.style.top = `${position.y}px`;
document.body.appendChild(dummyTarget);
}
// Clear target file since we're handling multiple
this.dropdownComponent.setTargetFile(null);
// Set up callback for batch add
this.dropdownComponent.setOnStatusChange(async (statuses) => {
if (statuses.length > 0) {
// We'll use the explicitly selected status for the add operation
// Get only the newly selected status
const statusToAdd = statuses[0]; // Use the first selected status
if (statusToAdd && statusToAdd !== 'unknown') {
// Add this status to all files
for (const file of files) {
await this.statusService.addNoteStatus(statusToAdd, file);
}
// Dispatch events for UI update
window.dispatchEvent(new CustomEvent('note-status:batch-update-complete', {
detail: {
statuses: [statusToAdd],
fileCount: files.length,
mode: 'add'
}
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}
}
});
// Reset to unknown status for selection
this.dropdownComponent.updateStatuses(['unknown']);
// Show the dropdown
if (position) {
this.dropdownComponent.open(dummyTarget, position);
} else {
const center = {
x: window.innerWidth / 2,
y: window.innerHeight / 2
};
this.dropdownComponent.open(dummyTarget, center);
}
// Clean up dummy target
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
}, 100);
})
);
}
// Add option to open the batch modal for more advanced options
menu.addItem((item) =>
item
.setTitle('Open batch update modal...')
.setIcon('lucide-edit')
.onClick(() => {
window.dispatchEvent(new CustomEvent('note-status:open-batch-modal'));
})
);
// Show the menu
if (position) {
menu.showAtPosition(position);
} else {
menu.showAtMouseEvent(new MouseEvent('contextmenu'));
}
}
/**
* Shows a context menu for a single file
*/
public showForFile(file: TFile, event: MouseEvent): void {
// Create position from event
const position = { x: event.clientX, y: event.clientY };
// Modify the onStatusChange callback specifically for this file
const originalCallback = this.dropdownComponent.getOnStatusChange();
// Set a custom callback for this specific file operation
this.dropdownComponent.setOnStatusChange(async (statuses) => {
if (statuses.length > 0) {
// Update the file with the selected status
if (this.settings.useMultipleStatuses) {
// If the status is already applied, toggle it off
const currentStatuses = this.statusService.getFileStatuses(file);
if (currentStatuses.includes(statuses[0])) {
await this.statusService.removeNoteStatus(statuses[0], file);
} else {
// Add the status
await this.statusService.addNoteStatus(statuses[0], file);
}
} else {
// Replace with the single status
await this.statusService.updateNoteStatuses([statuses[0]], file);
}
// Force explorer icon update for this specific file
this.app.metadataCache.trigger('changed', file);
// Directly update the explorer icon
setTimeout(() => {
const explorerIntegration = (this.app as any).plugins.plugins['note-status']?.explorerIntegration;
if (explorerIntegration) {
explorerIntegration.updateFileExplorerIcons(file);
}
}, 50);
// Dispatch events to update other UI components
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses, file: file.path }
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
// Force a full UI refresh to ensure all elements are updated
setTimeout(() => {
window.dispatchEvent(new CustomEvent('note-status:force-refresh'));
}, 100);
}
});
// Show the dropdown
this.showForFiles([file], position);
// Restore the original callback after a delay
setTimeout(() => {
this.dropdownComponent.setOnStatusChange(originalCallback);
}, 300);
}
/**
* Clean up resources
*/
public dispose(): void {
this.dropdownComponent.dispose();
}
}

View file

@ -1,311 +0,0 @@
import { App, TFile, debounce } from 'obsidian';
import { FileExplorerView, NoteStatusSettings } from '../models/types';
import { StatusService } from '../services/status-service';
/**
* Enhanced file explorer integration for displaying status icons with performance optimizations
*/
export class ExplorerIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private iconUpdateQueue: Set<string> = new Set();
private isProcessingQueue = false;
// Debounced update function for better performance
private debouncedUpdateAll = debounce(
() => this.processUpdateQueue(),
100,
true
);
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
}
/**
* Update settings reference and refresh icons
*/
public updateSettings(settings: NoteStatusSettings): void {
const previousShowIcons = this.settings.showStatusIconsInExplorer;
this.settings = settings;
// Update icons based on new settings
if (previousShowIcons !== settings.showStatusIconsInExplorer) {
if (settings.showStatusIconsInExplorer) {
this.updateAllFileExplorerIcons();
} else {
this.removeAllFileExplorerIcons();
}
} else if (settings.showStatusIconsInExplorer) {
// If setting hasn't changed but is enabled, refresh the icons
// This handles cases where custom statuses or colors changed
this.updateAllFileExplorerIcons();
}
}
private findFileExplorerView(): FileExplorerView | null {
// Try the standard method first
const leaf = this.app.workspace.getLeavesOfType('file-explorer')[0];
if (leaf && leaf.view) {
return leaf.view as FileExplorerView;
}
// If that fails, try to find it by searching all leaves
for (const leaf of this.app.workspace.getLeavesOfType('')) {
if (leaf.view && 'fileItems' in leaf.view) {
return leaf.view as FileExplorerView;
}
}
return null;
}
/**
* Add a file to the update queue
*/
public queueFileUpdate(file: TFile): void {
if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
this.iconUpdateQueue.add(file.path);
this.debouncedUpdateAll();
}
/**
* Process the queue of files that need icon updates
*/
private async processUpdateQueue(): Promise<void> {
if (this.isProcessingQueue || this.iconUpdateQueue.size === 0) return;
this.isProcessingQueue = true;
try {
const fileExplorerView = this.findFileExplorerView();
if (!fileExplorerView || !fileExplorerView.fileItems) {
// Schedule retry if view not found
setTimeout(() => this.debouncedUpdateAll(), 200);
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);
}
this.iconUpdateQueue.delete(filePath);
}
} catch (error) {
console.error('Note Status: Error processing file update queue', error);
} finally {
this.isProcessingQueue = false;
// If more files were added while processing, process them too
if (this.iconUpdateQueue.size > 0) {
this.debouncedUpdateAll();
}
}
}
/**
* 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;
}
const titleEl = fileItem.titleEl || fileItem.selfEl;
if (!titleEl) {
console.debug(`Note Status: Title element not found for ${file.path}`);
return;
}
// 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
const existingIcons = titleEl.querySelectorAll('.note-status-icon, .note-status-icon-container');
existingIcons.forEach(icon => icon.remove());
// Hide unknown status if setting is enabled
if (this.settings.hideUnknownStatusInExplorer &&
statuses.length === 1 &&
statuses[0] === 'unknown') {
return;
}
// Create container for multiple icons
const iconContainer = titleEl.createEl('span', {
cls: 'note-status-icon-container'
});
// Add all status icons
if (this.settings.useMultipleStatuses && statuses.length > 0 && statuses[0] !== 'unknown') {
// Add all icons if using multiple statuses
statuses.forEach(status => {
const icon = this.statusService.getStatusIcon(status);
const iconEl = iconContainer.createEl('span', {
cls: `note-status-icon nav-file-tag status-${status}`,
text: icon
});
// Add tooltip with status name
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}`: status;
iconEl.setAttribute('aria-label', tooltipValue);
iconEl.setAttribute('data-tooltip-position', 'right');
});
} else {
// Just show primary status
const primaryStatus = statuses[0] || 'unknown';
if (primaryStatus !== 'unknown' || !this.settings.autoHideStatusBar) {
const icon = this.statusService.getStatusIcon(primaryStatus);
const iconEl = iconContainer.createEl('span', {
cls: `note-status-icon nav-file-tag status-${primaryStatus}`,
text: icon
});
// Add tooltip with status name
const statusObj = this.statusService.getAllStatuses().find(s => s.name === primaryStatus);
const tooltipValue = statusObj?.description ? `${primaryStatus} - ${statusObj.description}`: primaryStatus;
iconEl.setAttribute('aria-label', tooltipValue);
iconEl.setAttribute('data-tooltip-position', 'right');
}
}
// Remove container if empty (no icons added)
if (iconContainer.childElementCount === 0) {
iconContainer.remove();
}
} catch (error) {
console.error(`Note Status: Error updating icon for ${file.path}`, error);
}
}
/**
* 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;
// Add direct immediate update for critical files (like active file)
const activeFile = this.app.workspace.getActiveFile();
const isActiveFile = activeFile && activeFile.path === file.path;
// For active file, update immediately and also queue
if (isActiveFile) {
this.updateSingleFileIconDirectly(file);
}
// Also queue update for normal processing
this.queueFileUpdate(file);
}
private updateSingleFileIconDirectly(file: TFile): void {
try {
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 fileItem = fileExplorerView.fileItems[file.path];
if (!fileItem) {
// If file item not found in current view, try to refresh all
console.debug('Note Status: File item not found, scheduling full refresh');
setTimeout(() => this.updateAllFileExplorerIcons(), 50);
return;
}
this.updateSingleFileIcon(file, fileExplorerView);
} 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
*/
public updateAllFileExplorerIcons(): void {
// Remove all icons if setting is turned off
if (!this.settings.showStatusIconsInExplorer) {
this.removeAllFileExplorerIcons();
return;
}
// Queue all markdown files for update
const files = this.app.vault.getMarkdownFiles();
files.forEach(file => this.queueFileUpdate(file));
}
/**
* 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 fileExplorerView = fileExplorer.view as FileExplorerView;
if (!fileExplorerView.fileItems) return;
Object.values(fileExplorerView.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());
});
// Clear the update queue
this.iconUpdateQueue.clear();
}
/**
* 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 selectedFiles: TFile[] = [];
Object.entries(fileExplorerView.fileItems).forEach(([_, item]) => {
if (item.el?.classList.contains('is-selected') &&
item.file instanceof TFile &&
item.file.extension === 'md') {
selectedFiles.push(item.file);
}
});
return selectedFiles;
}
/**
* Clean up when plugin is unloaded
*/
public unload(): void {
this.removeAllFileExplorerIcons();
this.debouncedUpdateAll.cancel();
}
}

View file

@ -0,0 +1,357 @@
import { App, TFile, debounce } from 'obsidian';
import { FileExplorerView, NoteStatusSettings } from '../../models/types';
import { StatusService } from '../../services/status-service';
/**
* Enhanced file explorer integration for displaying status icons
* Includes performance optimizations and error handling
*/
export class ExplorerIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private iconUpdateQueue: Set<string> = new Set();
private isProcessingQueue = false;
// Debounced update function for better performance
private debouncedUpdateAll = debounce(
() => this.processUpdateQueue(),
100,
true
);
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
}
/**
* Update settings reference and refresh icons
*/
public updateSettings(settings: NoteStatusSettings): void {
const previousShowIcons = this.settings.showStatusIconsInExplorer;
this.settings = settings;
this.handleSettingsChange(previousShowIcons);
}
/**
* Handle settings change and update UI accordingly
*/
private handleSettingsChange(previousShowIcons: boolean): void {
// Update icons based on new settings
if (previousShowIcons !== this.settings.showStatusIconsInExplorer) {
if (this.settings.showStatusIconsInExplorer) {
this.updateAllFileExplorerIcons();
} else {
this.removeAllFileExplorerIcons();
}
} else if (this.settings.showStatusIconsInExplorer) {
// If setting hasn't changed but is enabled, refresh the icons
// This handles cases where custom statuses or colors changed
this.updateAllFileExplorerIcons();
}
}
/**
* Find the file explorer view
*/
private findFileExplorerView(): FileExplorerView | null {
// Try the standard method first
const leaf = this.app.workspace.getLeavesOfType('file-explorer')[0];
if (leaf && leaf.view) {
return leaf.view as FileExplorerView;
}
// If that fails, try to find it by searching all leaves
for (const leaf of this.app.workspace.getLeavesOfType('')) {
if (leaf.view && 'fileItems' in leaf.view) {
return leaf.view as FileExplorerView;
}
}
return null;
}
/**
* Add a file to the update queue
*/
public queueFileUpdate(file: TFile): void {
if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
this.iconUpdateQueue.add(file.path);
this.debouncedUpdateAll();
}
/**
* Process the queue of files that need icon updates
*/
private async processUpdateQueue(): Promise<void> {
if (this.isProcessingQueue || this.iconUpdateQueue.size === 0) return;
this.isProcessingQueue = true;
try {
const fileExplorerView = this.findFileExplorerView();
if (!fileExplorerView || !fileExplorerView.fileItems) {
// Schedule retry if view not found
setTimeout(() => this.debouncedUpdateAll(), 200);
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);
}
this.iconUpdateQueue.delete(filePath);
}
} catch (error) {
console.error('Note Status: Error processing file update queue', error);
} finally {
this.isProcessingQueue = false;
// If more files were added while processing, process them too
if (this.iconUpdateQueue.size > 0) {
this.debouncedUpdateAll();
}
}
}
/**
* 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;
}
const titleEl = fileItem.titleEl || fileItem.selfEl;
if (!titleEl) {
console.debug(`Note Status: Title element not found for ${file.path}`);
return;
}
// 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
this.removeExistingIcons(titleEl);
// Hide unknown status if setting is enabled
if (this.shouldHideUnknownStatus(statuses)) {
return;
}
this.addStatusIcons(titleEl, statuses);
} catch (error) {
console.error(`Note Status: Error updating icon for ${file.path}`, error);
}
}
/**
* Check if unknown status should be hidden
*/
private shouldHideUnknownStatus(statuses: string[]): boolean {
return this.settings.hideUnknownStatusInExplorer &&
statuses.length === 1 &&
statuses[0] === 'unknown';
}
/**
* Remove existing status icons from an element
*/
private removeExistingIcons(element: HTMLElement): void {
const existingIcons = element.querySelectorAll('.note-status-icon, .note-status-icon-container');
existingIcons.forEach(icon => 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'
});
// Add all status icons
if (this.settings.useMultipleStatuses && statuses.length > 0 && statuses[0] !== 'unknown') {
// Add all icons if using multiple statuses
this.addMultipleStatusIcons(iconContainer, statuses);
} else {
// Just show primary status
this.addPrimaryStatusIcon(iconContainer, statuses);
}
// Remove container if empty (no icons added)
if (iconContainer.childElementCount === 0) {
iconContainer.remove();
}
}
/**
* Add multiple status icons to a container
*/
private addMultipleStatusIcons(container: HTMLElement, statuses: string[]): void {
statuses.forEach(status => {
this.addSingleStatusIcon(container, status);
});
}
/**
* Add primary status icon to a container
*/
private addPrimaryStatusIcon(container: HTMLElement, statuses: string[]): void {
const primaryStatus = statuses[0] || 'unknown';
if (primaryStatus !== 'unknown' || !this.settings.autoHideStatusBar) {
this.addSingleStatusIcon(container, primaryStatus);
}
}
/**
* 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
});
// Add tooltip with status name
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}`: status;
iconEl.setAttribute('aria-label', tooltipValue);
iconEl.setAttribute('data-tooltip-position', 'right');
}
/**
* 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;
// Add direct immediate update for critical files (like active file)
const activeFile = this.app.workspace.getActiveFile();
const isActiveFile = activeFile && activeFile.path === file.path;
// For active file, update immediately and also queue
if (isActiveFile) {
this.updateSingleFileIconDirectly(file);
}
// Also queue update for normal processing
this.queueFileUpdate(file);
}
/**
* Update a single file icon directly (for immediate updates)
*/
private updateSingleFileIconDirectly(file: TFile): void {
try {
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 fileItem = fileExplorerView.fileItems[file.path];
if (!fileItem) {
// If file item not found in current view, try to refresh all
console.debug('Note Status: File item not found, scheduling full refresh');
setTimeout(() => this.updateAllFileExplorerIcons(), 50);
return;
}
this.updateSingleFileIcon(file, fileExplorerView);
} 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
*/
public updateAllFileExplorerIcons(): void {
// Remove all icons if setting is turned off
if (!this.settings.showStatusIconsInExplorer) {
this.removeAllFileExplorerIcons();
return;
}
// Queue all markdown files for update
const files = this.app.vault.getMarkdownFiles();
files.forEach(file => this.queueFileUpdate(file));
}
/**
* 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 fileExplorerView = fileExplorer.view as FileExplorerView;
if (!fileExplorerView.fileItems) return;
Object.values(fileExplorerView.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());
});
// Clear the update queue
this.iconUpdateQueue.clear();
}
/**
* 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 selectedFiles: TFile[] = [];
Object.entries(fileExplorerView.fileItems).forEach(([_, item]) => {
if (item.el?.classList.contains('is-selected') &&
item.file instanceof TFile &&
item.file.extension === 'md') {
selectedFiles.push(item.file);
}
});
return selectedFiles;
}
/**
* Clean up when plugin is unloaded
*/
public unload(): void {
this.removeAllFileExplorerIcons();
this.debouncedUpdateAll.cancel();
}
}

View file

@ -0,0 +1,371 @@
import { App, Menu, TFile } from 'obsidian';
import { NoteStatusSettings } from '../../models/types';
import { StatusService } from '../../services/status-service';
import { StatusDropdownComponent } from '../components/status-dropdown-component';
/**
* Handles context menu interactions for status changes
*/
export class StatusContextMenu {
private settings: NoteStatusSettings;
private statusService: StatusService;
public app: App;
private dropdownComponent: StatusDropdownComponent;
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.dropdownComponent = new StatusDropdownComponent(app, statusService, settings);
// Set up the callback to handle status changes
this.setupStatusChangeCallback();
}
/**
* Set up the callback for status changes
*/
private setupStatusChangeCallback(): void {
this.dropdownComponent.setOnStatusChange((statuses) => {
// Notify UI of status changes
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses }
}));
// Force a full UI refresh to ensure all elements are updated
window.dispatchEvent(new CustomEvent('note-status:force-refresh'));
// Also trigger explorer update specifically
setTimeout(() => {
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}, 50);
});
}
/**
* Updates settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.dropdownComponent.updateSettings(settings);
}
/**
* Shows the context menu for changing a status of one or more files
*/
public showForFiles(files: TFile[], position?: { x: number; y: number }): void {
if (files.length === 0) return;
// For single file, we can show the dropdown directly without menu
if (files.length === 1) {
this.showSingleFileDropdown(files[0], position);
return;
}
// For multiple files, show a menu first with options for batch actions
this.showMultipleFilesMenu(files, position);
}
/**
* Show dropdown for a single file
*/
private showSingleFileDropdown(file: TFile, position?: { x: number; y: number }): void {
// Get the current statuses for this file
const currentStatuses = this.statusService.getFileStatuses(file);
// Create a dummy target element for dropdown positioning
const dummyTarget = this.createDummyTarget(position);
// Set the target file for the dropdown to update
this.dropdownComponent.setTargetFile(file);
// Update dropdown with current statuses
this.dropdownComponent.updateStatuses(currentStatuses);
// Show the dropdown
if (position) {
this.dropdownComponent.open(dummyTarget, position);
} else {
// If no position, use screen center
const center = {
x: window.innerWidth / 2,
y: window.innerHeight / 2
};
this.dropdownComponent.open(dummyTarget, center);
}
// Clean up dummy target after dropdown is shown
this.cleanupDummyTarget(dummyTarget);
}
/**
* Show menu for multiple files
*/
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) => {
item.setTitle(`Update ${files.length} files`)
.setDisabled(true);
return item;
});
// Add "Replace with status" option
menu.addItem((item) =>
item
.setTitle('Replace with status...')
.setIcon('tag')
.onClick(() => {
this.showReplaceStatusDropdown(files, position);
})
);
// Add "Add status" option (only for multiple status mode)
if (this.settings.useMultipleStatuses) {
menu.addItem((item) =>
item
.setTitle('Add status...')
.setIcon('plus')
.onClick(() => {
this.showAddStatusDropdown(files, position);
})
);
}
// Add option to open the batch modal for more advanced options
menu.addItem((item) =>
item
.setTitle('Open batch update modal...')
.setIcon('lucide-edit')
.onClick(() => {
window.dispatchEvent(new CustomEvent('note-status:open-batch-modal'));
})
);
// Show the menu
if (position) {
menu.showAtPosition(position);
} else {
menu.showAtMouseEvent(new MouseEvent('contextmenu'));
}
}
/**
* Show dropdown for replacing statuses on multiple files
*/
private showReplaceStatusDropdown(files: TFile[], position?: { x: number; y: number }): void {
// Create dummy element for positioning
const dummyTarget = this.createDummyTarget(position);
// Clear target file since we're handling multiple
this.dropdownComponent.setTargetFile(null);
// Set up callback for multiple files
this.dropdownComponent.setOnStatusChange(async (statuses) => {
if (statuses.length > 0) {
// Batch update all files with the selected statuses
await this.statusService.batchUpdateStatuses(files, statuses, 'replace');
// Dispatch events for UI update
window.dispatchEvent(new CustomEvent('note-status:batch-update-complete', {
detail: {
statuses,
fileCount: files.length,
mode: 'replace'
}
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}
});
// Reset to unknown status for selection
this.dropdownComponent.updateStatuses(['unknown']);
// Show the dropdown
this.showDropdownWithPosition(dummyTarget, position);
// Clean up dummy target
this.cleanupDummyTarget(dummyTarget);
}
/**
* Show dropdown for adding statuses to multiple files
*/
private showAddStatusDropdown(files: TFile[], position?: { x: number; y: number }): void {
// Create dummy element for positioning
const dummyTarget = this.createDummyTarget(position);
// Clear target file since we're handling multiple
this.dropdownComponent.setTargetFile(null);
// Set up callback for batch add
this.dropdownComponent.setOnStatusChange(async (statuses) => {
if (statuses.length > 0) {
// Get only the newly selected status
const statusToAdd = statuses[0]; // Use the first selected status
if (statusToAdd && statusToAdd !== 'unknown') {
// Add this status to all files
for (const file of files) {
await this.statusService.addNoteStatus(statusToAdd, file);
}
// Dispatch events for UI update
window.dispatchEvent(new CustomEvent('note-status:batch-update-complete', {
detail: {
statuses: [statusToAdd],
fileCount: files.length,
mode: 'add'
}
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}
}
});
// Reset to unknown status for selection
this.dropdownComponent.updateStatuses(['unknown']);
// Show the dropdown
this.showDropdownWithPosition(dummyTarget, position);
// Clean up dummy target
this.cleanupDummyTarget(dummyTarget);
}
/**
* Shows a dropdown at a position
*/
private showDropdownWithPosition(targetEl: HTMLElement, position?: { x: number; y: number }): void {
if (position) {
this.dropdownComponent.open(targetEl, position);
} else {
const center = {
x: window.innerWidth / 2,
y: window.innerHeight / 2
};
this.dropdownComponent.open(targetEl, center);
}
}
/**
* Create a dummy target element for positioning
*/
private createDummyTarget(position?: { x: number; y: number }): HTMLElement {
const dummyTarget = document.createElement('div');
if (position) {
dummyTarget.style.position = 'fixed';
dummyTarget.style.left = `${position.x}px`;
dummyTarget.style.top = `${position.y}px`;
dummyTarget.style.width = '0';
dummyTarget.style.height = '0';
document.body.appendChild(dummyTarget);
}
return dummyTarget;
}
/**
* Clean up dummy target element
*/
private cleanupDummyTarget(dummyTarget: HTMLElement): void {
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
}, 100);
}
/**
* Shows a context menu for a single file
*/
public showForFile(file: TFile, event: MouseEvent): void {
// Create position from event
const position = { x: event.clientX, y: event.clientY };
// Modify the onStatusChange callback specifically for this file
const originalCallback = this.dropdownComponent.getOnStatusChange();
// Set a custom callback for this specific file operation
this.dropdownComponent.setOnStatusChange(async (statuses) => {
if (statuses.length > 0) {
await this.handleStatusUpdateForFile(file, statuses);
}
});
// Show the dropdown
this.showForFiles([file], position);
// Restore the original callback after a delay
setTimeout(() => {
this.dropdownComponent.setOnStatusChange(originalCallback);
}, 300);
}
/**
* Handle status update for a specific file
*/
private async handleStatusUpdateForFile(file: TFile, statuses: string[]): Promise<void> {
if (statuses.length === 0) return;
// Update the file with the selected status
if (this.settings.useMultipleStatuses) {
// If the status is already applied, toggle it off
const currentStatuses = this.statusService.getFileStatuses(file);
if (currentStatuses.includes(statuses[0])) {
await this.statusService.removeNoteStatus(statuses[0], file);
} else {
// Add the status
await this.statusService.addNoteStatus(statuses[0], file);
}
} else {
// Replace with the single status
await this.statusService.updateNoteStatuses([statuses[0]], file);
}
// Force explorer icon update for this specific file
this.app.metadataCache.trigger('changed', file);
// Directly update the explorer icon
this.updateExplorerIcon(file);
// Dispatch events to update other UI components
this.triggerUIUpdates(statuses, file);
}
/**
* Update explorer icon for a file
*/
private updateExplorerIcon(file: TFile): void {
setTimeout(() => {
const explorerIntegration = (this.app as any).plugins.plugins['note-status']?.explorerIntegration;
if (explorerIntegration) {
explorerIntegration.updateFileExplorerIcons(file);
}
}, 50);
}
/**
* Trigger UI updates after status changes
*/
private triggerUIUpdates(statuses: string[], file: TFile): void {
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses, file: file.path }
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
// Force a full UI refresh to ensure all elements are updated
setTimeout(() => {
window.dispatchEvent(new CustomEvent('note-status:force-refresh'));
}, 100);
}
/**
* Clean up resources
*/
public dispose(): void {
this.dropdownComponent.dispose();
}
}

View file

@ -1,411 +0,0 @@
import { App, Modal, TFile, Notice, setIcon } from 'obsidian';
import { NoteStatusSettings } from '../models/types';
import { StatusService } from '../services/status-service';
/**
* Enhanced modal for batch updating statuses with improved UX
*/
export class BatchStatusModal extends Modal {
settings: NoteStatusSettings;
statusService: StatusService;
app: App;
selectedFiles: TFile[] = [];
selectedStatuses: string[] = [];
updateMode: 'replace' | 'add' = 'replace';
searchTerm = '';
private fileSelect: HTMLSelectElement | null = null;
private statusSelect: HTMLSelectElement | null = null;
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
super(app);
this.app = app;
this.settings = settings;
this.statusService = statusService;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('note-status-batch-modal');
// Header with icon and title
const headerEl = contentEl.createDiv({ cls: 'note-status-modal-header' });
const headerIcon = headerEl.createDiv({ cls: 'note-status-modal-icon' });
setIcon(headerIcon, 'tag');
headerEl.createEl('h2', { text: 'Batch Update Note Status', cls: 'note-status-modal-title' });
// Create file selection with search filter
this.createFileSelectionSection(contentEl);
// Create status selection section
this.createStatusSelectionSection(contentEl);
// Add action buttons
this.createActionButtons(contentEl);
// Initial file population
this.populateFiles();
// Focus search input
setTimeout(() => {
const searchInput = contentEl.querySelector('.note-status-modal-search-input');
if (searchInput instanceof HTMLInputElement) {
searchInput.focus();
}
}, 10);
}
/**
* Creates the file selection section with search
*/
private createFileSelectionSection(containerEl: HTMLElement) {
const fileSection = containerEl.createDiv({ cls: 'note-status-modal-section' });
// Section header
const fileSectionHeader = fileSection.createDiv({ cls: 'note-status-section-header' });
fileSectionHeader.createEl('h3', { text: 'Select Files', cls: 'note-status-section-title' });
// File count indicator
const fileCountEl = fileSectionHeader.createSpan({
cls: 'note-status-file-count',
text: '0 selected'
});
// Search container
const searchContainer = fileSection.createDiv({ cls: 'note-status-modal-search' });
const searchWrapper = searchContainer.createDiv({ cls: 'note-status-search-wrapper' });
// Search icon
const searchIcon = searchWrapper.createDiv({ cls: 'note-status-search-icon' });
setIcon(searchIcon, 'search');
// Search input
const searchInput = searchWrapper.createEl('input', {
type: 'text',
placeholder: 'Filter files...',
cls: 'note-status-modal-search-input'
});
// Clear search button
const clearSearchBtn = searchWrapper.createDiv({
cls: 'note-status-search-clear-button'
});
setIcon(clearSearchBtn, 'x');
clearSearchBtn.style.display = 'none';
// File selection container with scrollable area
const fileSelectContainer = fileSection.createDiv({ cls: 'note-status-file-select-container' });
this.fileSelect = fileSelectContainer.createEl('select', {
cls: 'note-status-file-select',
attr: { multiple: 'true', size: '8' }
});
// Search functionality
searchInput.addEventListener('input', () => {
this.searchTerm = searchInput.value;
this.populateFiles();
clearSearchBtn.style.display = this.searchTerm ? 'flex' : 'none';
});
// Clear search
clearSearchBtn.addEventListener('click', () => {
searchInput.value = '';
this.searchTerm = '';
this.populateFiles();
clearSearchBtn.style.display = 'none';
searchInput.focus();
});
// Update selection count when files are selected
this.fileSelect.addEventListener('change', () => {
this.selectedFiles = Array.from(this.fileSelect?.selectedOptions || [])
.map(opt => this.app.vault.getMarkdownFiles().find(f => f.path === opt.value))
.filter((file): file is TFile => file instanceof TFile);
fileCountEl.setText(`${this.selectedFiles.length} selected`);
if (this.selectedFiles.length > 0) {
fileCountEl.addClass('has-selection');
} else {
fileCountEl.removeClass('has-selection');
}
});
}
/**
* Creates the status selection section
*/
private createStatusSelectionSection(containerEl: HTMLElement) {
const statusSection = containerEl.createDiv({ cls: 'note-status-modal-section' });
// Section header
const statusSectionHeader = statusSection.createDiv({ cls: 'note-status-section-header' });
statusSectionHeader.createEl('h3', { text: 'Select Statuses', cls: 'note-status-section-title' });
// Status count indicator
const statusCountEl = statusSectionHeader.createSpan({
cls: 'note-status-status-count',
text: '0 selected'
});
// Add mode selection if multiple statuses are allowed
if (this.settings.useMultipleStatuses) {
const modeContainer = statusSection.createDiv({ cls: 'note-status-mode-container' });
// Create radio button group with modern styling
const replaceOption = this.createModeRadioOption(
modeContainer,
'mode-replace',
'Replace existing statuses',
'replace',
true
);
const addOption = this.createModeRadioOption(
modeContainer,
'mode-add',
'Add to existing statuses',
'add',
false
);
// Add change event listeners
replaceOption.addEventListener('change', () => {
if (replaceOption.checked) this.updateMode = 'replace';
});
addOption.addEventListener('change', () => {
if (addOption.checked) this.updateMode = 'add';
});
}
// Status selection container
const statusSelectContainer = statusSection.createDiv({ cls: 'note-status-status-select-container' });
// Create status list with custom styling
if (this.settings.useMultipleStatuses) {
// Create multi-select status list
this.statusSelect = statusSelectContainer.createEl('select', {
cls: 'note-status-status-select',
attr: {
multiple: 'true',
size: '5'
}
});
} else {
// Single-select dropdown
this.statusSelect = statusSelectContainer.createEl('select', {
cls: 'note-status-status-select'
});
}
// Get all available statuses
const allStatuses = this.statusService.getAllStatuses();
// Add status options (excluding 'unknown')
allStatuses
.filter(status => status.name !== 'unknown')
.forEach(status => {
const option = this.statusSelect?.createEl('option', {
text: `${status.icon} ${status.name}`,
value: status.name
});
if (option) {
option.classList.add(`status-${status.name}`);
}
});
// Update selection count when statuses are selected
this.statusSelect?.addEventListener('change', () => {
this.selectedStatuses = Array.from(this.statusSelect?.selectedOptions || [])
.map(opt => opt.value);
statusCountEl.setText(`${this.selectedStatuses.length} selected`);
if (this.selectedStatuses.length > 0) {
statusCountEl.addClass('has-selection');
} else {
statusCountEl.removeClass('has-selection');
}
});
}
/**
* Creates a radio button option for update mode
*/
private createModeRadioOption(
container: HTMLElement,
id: string,
labelText: string,
value: 'replace' | 'add',
checked: boolean
): HTMLInputElement {
const optionWrapper = container.createDiv({ cls: 'note-status-mode-option' });
const radio = optionWrapper.createEl('input', {
type: 'radio',
attr: {
name: 'status-update-mode',
id: id,
value: value
},
cls: 'note-status-mode-radio'
});
radio.checked = checked;
optionWrapper.createEl('label', {
text: labelText,
attr: { for: id },
cls: 'note-status-mode-label'
});
// Make the whole option clickable
optionWrapper.addEventListener('click', () => {
radio.checked = true;
this.updateMode = value;
// Update UI to show active state
container.querySelectorAll('.note-status-mode-option').forEach(el =>
el.removeClass('is-active'));
optionWrapper.addClass('is-active');
});
// Set initial active state
if (checked) {
optionWrapper.addClass('is-active');
}
return radio;
}
/**
* Creates action buttons
*/
private createActionButtons(containerEl: HTMLElement) {
const buttonContainer = containerEl.createDiv({ cls: 'note-status-modal-buttons' });
// Select all files button
const selectAllButton = buttonContainer.createEl('button', {
text: 'Select All Files',
cls: 'note-status-select-all'
});
selectAllButton.addEventListener('click', () => {
if (this.fileSelect) {
for (const option of Array.from(this.fileSelect.options)) {
option.selected = true;
}
// Trigger change event to update selection count
this.fileSelect.dispatchEvent(new Event('change'));
}
});
// Apply button
const applyButton = buttonContainer.createEl('button', {
text: 'Apply Changes',
cls: 'mod-cta note-status-apply-button'
});
applyButton.addEventListener('click', async () => {
if (this.selectedFiles.length === 0) {
new Notice('No files selected');
return;
}
if (this.selectedStatuses.length === 0) {
new Notice('No statuses selected');
return;
}
try {
// Show loading state
applyButton.disabled = true;
applyButton.setText('Applying...');
// Apply changes
await this.statusService.batchUpdateStatuses(
this.selectedFiles,
this.selectedStatuses,
this.updateMode
);
this.close();
// Show success notification
const statusText = this.selectedStatuses.length === 1
? this.selectedStatuses[0]
: `${this.selectedStatuses.length} statuses`;
new Notice(`Updated ${this.selectedFiles.length} file${this.selectedFiles.length === 1 ? '' : 's'} with ${statusText}`);
} catch (error) {
console.error('Error applying status changes:', error);
new Notice('Error applying changes');
// Reset button state
applyButton.disabled = false;
applyButton.setText('Apply Changes');
}
});
}
/**
* Populates file list with filtering
*/
private populateFiles() {
if (!this.fileSelect) return;
this.fileSelect.empty();
const mdFiles = this.app.vault.getMarkdownFiles();
// Filter by search term if provided
const filteredFiles = this.searchTerm
? mdFiles.filter(file => file.path.toLowerCase().includes(this.searchTerm.toLowerCase()))
: mdFiles;
// Sort files by path
filteredFiles.sort((a, b) => a.path.localeCompare(b.path));
// Create options
filteredFiles.forEach(file => {
const statuses = this.statusService.getFileStatuses(file);
const statusIcons = statuses.map(s => this.statusService.getStatusIcon(s)).join(' ');
const option = this.fileSelect?.createEl('option', {
value: file.path
});
if (option) {
// Create structured option content
const fileName = file.basename;
const filePath = file.parent ? file.parent.path : '';
// Set display text with path info
option.setText(`${fileName} ${statusIcons}`);
if (filePath) {
option.setAttribute('title', `${filePath}/${fileName}`);
}
// Add status class for styling
if (statuses.length > 0) {
option.classList.add(`status-${statuses[0]}`);
}
}
});
// Update empty state message
if (filteredFiles.length === 0) {
const emptyOption = this.fileSelect.createEl('option', {
text: this.searchTerm
? `No files match "${this.searchTerm}"`
: 'No markdown files found',
attr: { disabled: 'true' }
});
emptyOption.classList.add('note-status-empty-option');
}
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -0,0 +1,489 @@
import { App, Modal, TFile, Notice, setIcon } from 'obsidian';
import { NoteStatusSettings } from '../../models/types';
import { StatusService } from '../../services/status-service';
/**
* Enhanced modal for batch updating statuses with improved UX
*/
export class BatchStatusModal extends Modal {
settings: NoteStatusSettings;
statusService: StatusService;
app: App;
selectedFiles: TFile[] = [];
selectedStatuses: string[] = [];
updateMode: 'replace' | 'add' = 'replace';
searchTerm = '';
private fileSelect: HTMLSelectElement | null = null;
private statusSelect: HTMLSelectElement | null = null;
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
super(app);
this.app = app;
this.settings = settings;
this.statusService = statusService;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('note-status-batch-modal');
// Create the modal layout
this.createModalLayout(contentEl);
// Focus search input
setTimeout(() => {
const searchInput = contentEl.querySelector('.note-status-modal-search-input');
if (searchInput instanceof HTMLInputElement) {
searchInput.focus();
}
}, 10);
}
/**
* Creates the main modal layout
*/
private createModalLayout(containerEl: HTMLElement): void {
// Header with icon and title
this.createModalHeader(containerEl);
// Create file selection with search filter
this.createFileSelectionSection(containerEl);
// Create status selection section
this.createStatusSelectionSection(containerEl);
// Add action buttons
this.createActionButtons(containerEl);
// Initial file population
this.populateFiles();
}
/**
* Creates the modal header
*/
private createModalHeader(containerEl: HTMLElement): void {
const headerEl = containerEl.createDiv({ cls: 'note-status-modal-header' });
const headerIcon = headerEl.createDiv({ cls: 'note-status-modal-icon' });
setIcon(headerIcon, 'tag');
headerEl.createEl('h2', { text: 'Batch Update Note Status', cls: 'note-status-modal-title' });
}
/**
* Creates the file selection section with search
*/
private createFileSelectionSection(containerEl: HTMLElement): void {
const fileSection = containerEl.createDiv({ cls: 'note-status-modal-section' });
// Section header
const fileSectionHeader = fileSection.createDiv({ cls: 'note-status-section-header' });
fileSectionHeader.createEl('h3', { text: 'Select Files', cls: 'note-status-section-title' });
// File count indicator
const fileCountEl = fileSectionHeader.createSpan({
cls: 'note-status-file-count',
text: '0 selected'
});
// Search container with icon
this.createSearchContainer(fileSection, fileCountEl);
// File selection container with scrollable area
const fileSelectContainer = fileSection.createDiv({ cls: 'note-status-file-select-container' });
this.fileSelect = fileSelectContainer.createEl('select', {
cls: 'note-status-file-select',
attr: { multiple: 'true', size: '8' }
});
// Update selection count when files are selected
this.fileSelect.addEventListener('change', () => {
this.updateFileSelectionCount(fileCountEl);
});
}
/**
* Creates search container with input and clear button
*/
private createSearchContainer(containerEl: HTMLElement, fileCountEl: HTMLElement): void {
const searchContainer = containerEl.createDiv({ cls: 'note-status-modal-search' });
const searchWrapper = searchContainer.createDiv({ cls: 'note-status-search-wrapper' });
// Search icon
const searchIcon = searchWrapper.createDiv({ cls: 'note-status-search-icon' });
setIcon(searchIcon, 'search');
// Search input
const searchInput = searchWrapper.createEl('input', {
type: 'text',
placeholder: 'Filter files...',
cls: 'note-status-modal-search-input'
});
// Clear search button
const clearSearchBtn = searchWrapper.createDiv({
cls: 'note-status-search-clear-button'
});
setIcon(clearSearchBtn, 'x');
clearSearchBtn.style.display = 'none';
// Search functionality
searchInput.addEventListener('input', () => {
this.searchTerm = searchInput.value;
this.populateFiles();
clearSearchBtn.style.display = this.searchTerm ? 'flex' : 'none';
});
// Clear search
clearSearchBtn.addEventListener('click', () => {
searchInput.value = '';
this.searchTerm = '';
this.populateFiles();
clearSearchBtn.style.display = 'none';
searchInput.focus();
});
}
/**
* Update the file selection count display
*/
private updateFileSelectionCount(fileCountEl: HTMLElement): void {
this.selectedFiles = Array.from(this.fileSelect?.selectedOptions || [])
.map(opt => this.app.vault.getMarkdownFiles().find(f => f.path === opt.value))
.filter((file): file is TFile => file instanceof TFile);
fileCountEl.setText(`${this.selectedFiles.length} selected`);
if (this.selectedFiles.length > 0) {
fileCountEl.addClass('has-selection');
} else {
fileCountEl.removeClass('has-selection');
}
}
/**
* Creates the status selection section
*/
private createStatusSelectionSection(containerEl: HTMLElement): void {
const statusSection = containerEl.createDiv({ cls: 'note-status-modal-section' });
// Section header
const statusSectionHeader = statusSection.createDiv({ cls: 'note-status-section-header' });
statusSectionHeader.createEl('h3', { text: 'Select Statuses', cls: 'note-status-section-title' });
// Status count indicator
const statusCountEl = statusSectionHeader.createSpan({
cls: 'note-status-status-count',
text: '0 selected'
});
// Add mode selection if multiple statuses are allowed
if (this.settings.useMultipleStatuses) {
this.createModeSelectionContainer(statusSection);
}
// Status selection container
const statusSelectContainer = statusSection.createDiv({ cls: 'note-status-status-select-container' });
// Create status list with custom styling
this.createStatusSelect(statusSelectContainer, statusCountEl);
}
/**
* Creates the mode selection container (replace vs add)
*/
private createModeSelectionContainer(containerEl: HTMLElement): void {
const modeContainer = containerEl.createDiv({ cls: 'note-status-mode-container' });
// Create radio button group with modern styling
const replaceOption = this.createModeRadioOption(
modeContainer,
'mode-replace',
'Replace existing statuses',
'replace',
true
);
const addOption = this.createModeRadioOption(
modeContainer,
'mode-add',
'Add to existing statuses',
'add',
false
);
// Add change event listeners
replaceOption.addEventListener('change', () => {
if (replaceOption.checked) this.updateMode = 'replace';
});
addOption.addEventListener('change', () => {
if (addOption.checked) this.updateMode = 'add';
});
}
/**
* Creates the status select dropdown/list
*/
private createStatusSelect(containerEl: HTMLElement, statusCountEl: HTMLElement): void {
if (this.settings.useMultipleStatuses) {
// Create multi-select status list
this.statusSelect = containerEl.createEl('select', {
cls: 'note-status-status-select',
attr: {
multiple: 'true',
size: '5'
}
});
} else {
// Single-select dropdown
this.statusSelect = containerEl.createEl('select', {
cls: 'note-status-status-select'
});
}
// Get all available statuses
const allStatuses = this.statusService.getAllStatuses();
// Add status options (excluding 'unknown')
allStatuses
.filter(status => status.name !== 'unknown')
.forEach(status => {
const option = this.statusSelect?.createEl('option', {
text: `${status.icon} ${status.name}`,
value: status.name
});
if (option) {
option.classList.add(`status-${status.name}`);
}
});
// Update selection count when statuses are selected
this.statusSelect?.addEventListener('change', () => {
this.updateStatusSelectionCount(statusCountEl);
});
}
/**
* Updates the status selection count display
*/
private updateStatusSelectionCount(statusCountEl: HTMLElement): void {
this.selectedStatuses = Array.from(this.statusSelect?.selectedOptions || [])
.map(opt => opt.value);
statusCountEl.setText(`${this.selectedStatuses.length} selected`);
if (this.selectedStatuses.length > 0) {
statusCountEl.addClass('has-selection');
} else {
statusCountEl.removeClass('has-selection');
}
}
/**
* Creates a radio button option for update mode
*/
private createModeRadioOption(
container: HTMLElement,
id: string,
labelText: string,
value: 'replace' | 'add',
checked: boolean
): HTMLInputElement {
const optionWrapper = container.createDiv({ cls: 'note-status-mode-option' });
const radio = optionWrapper.createEl('input', {
type: 'radio',
attr: {
name: 'status-update-mode',
id: id,
value: value
},
cls: 'note-status-mode-radio'
});
radio.checked = checked;
optionWrapper.createEl('label', {
text: labelText,
attr: { for: id },
cls: 'note-status-mode-label'
});
// Make the whole option clickable
optionWrapper.addEventListener('click', () => {
radio.checked = true;
this.updateMode = value;
// Update UI to show active state
container.querySelectorAll('.note-status-mode-option').forEach(el =>
el.removeClass('is-active'));
optionWrapper.addClass('is-active');
});
// Set initial active state
if (checked) {
optionWrapper.addClass('is-active');
}
return radio;
}
/**
* Creates action buttons
*/
private createActionButtons(containerEl: HTMLElement): void {
const buttonContainer = containerEl.createDiv({ cls: 'note-status-modal-buttons' });
// Select all files button
const selectAllButton = buttonContainer.createEl('button', {
text: 'Select All Files',
cls: 'note-status-select-all'
});
selectAllButton.addEventListener('click', () => {
this.selectAllFiles();
});
// Apply button
const applyButton = buttonContainer.createEl('button', {
text: 'Apply Changes',
cls: 'mod-cta note-status-apply-button'
});
applyButton.addEventListener('click', async () => {
await this.applyChanges(applyButton);
});
}
/**
* Select all files in the list
*/
private selectAllFiles(): void {
if (this.fileSelect) {
for (const option of Array.from(this.fileSelect.options)) {
option.selected = true;
}
// Trigger change event to update selection count
this.fileSelect.dispatchEvent(new Event('change'));
}
}
/**
* Apply the selected status changes
*/
private async applyChanges(applyButton: HTMLButtonElement): Promise<void> {
if (this.selectedFiles.length === 0) {
new Notice('No files selected');
return;
}
if (this.selectedStatuses.length === 0) {
new Notice('No statuses selected');
return;
}
try {
// Show loading state
applyButton.disabled = true;
applyButton.setText('Applying...');
// Apply changes
await this.statusService.batchUpdateStatuses(
this.selectedFiles,
this.selectedStatuses,
this.updateMode
);
this.close();
// Show success notification
const statusText = this.selectedStatuses.length === 1
? this.selectedStatuses[0]
: `${this.selectedStatuses.length} statuses`;
new Notice(`Updated ${this.selectedFiles.length} file${this.selectedFiles.length === 1 ? '' : 's'} with ${statusText}`);
} catch (error) {
console.error('Error applying status changes:', error);
new Notice('Error applying changes');
// Reset button state
applyButton.disabled = false;
applyButton.setText('Apply Changes');
}
}
/**
* Populates file list with filtering
*/
private populateFiles(): void {
if (!this.fileSelect) return;
this.fileSelect.empty();
const mdFiles = this.app.vault.getMarkdownFiles();
// Filter by search term if provided
const filteredFiles = this.searchTerm
? mdFiles.filter(file => file.path.toLowerCase().includes(this.searchTerm.toLowerCase()))
: mdFiles;
// Sort files by path
filteredFiles.sort((a, b) => a.path.localeCompare(b.path));
// Create options
filteredFiles.forEach(file => this.createFileOption(file));
// Update empty state message
if (filteredFiles.length === 0) {
this.createEmptyFileOption();
}
}
/**
* Create an option element for a file
*/
private createFileOption(file: TFile): void {
const statuses = this.statusService.getFileStatuses(file);
const statusIcons = statuses.map(s => this.statusService.getStatusIcon(s)).join(' ');
const option = this.fileSelect?.createEl('option', {
value: file.path
});
if (option) {
// Create structured option content
const fileName = file.basename;
const filePath = file.parent ? file.parent.path : '';
// Set display text with path info
option.setText(`${fileName} ${statusIcons}`);
if (filePath) {
option.setAttribute('title', `${filePath}/${fileName}`);
}
// Add status class for styling
if (statuses.length > 0) {
option.classList.add(`status-${statuses[0]}`);
}
}
}
/**
* Create an empty state option when no files match
*/
private createEmptyFileOption(): void {
if (!this.fileSelect) return;
const emptyOption = this.fileSelect.createEl('option', {
text: this.searchTerm
? `No files match "${this.searchTerm}"`
: 'No markdown files found',
attr: { disabled: 'true' }
});
emptyOption.classList.add('note-status-empty-option');
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -1,463 +0,0 @@
import { setIcon, TFile } from 'obsidian';
import { NoteStatusSettings, Status } from '../models/types';
import { StatusService } from '../services/status-service';
/**
* Unified dropdown component for status selection
*/
export class StatusDropdownComponent {
private app: any;
private statusService: StatusService;
private settings: NoteStatusSettings;
private dropdownElement: HTMLElement | null = null;
private currentStatuses: string[] = ['unknown'];
private onStatusChange: (statuses: string[]) => void;
private isOpen = false;
private animationDuration = 220;
private clickOutsideHandler: (e: MouseEvent) => void;
private targetFile: TFile | null = null;
constructor(app: any, statusService: StatusService, settings: NoteStatusSettings) {
this.app = app;
this.statusService = statusService;
this.settings = settings;
this.onStatusChange = () => {};
// Bind methods
this.handleClickOutside = this.handleClickOutside.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this);
}
/**
* Updates the current statuses
*/
public updateStatuses(statuses: string[] | string): void {
// Normalize input to always be an array
if (typeof statuses === 'string') {
this.currentStatuses = [statuses];
} else {
this.currentStatuses = [...statuses]; // Create a copy to ensure it's updated
}
// Update dropdown if it's open
if (this.isOpen && this.dropdownElement) {
this.refreshDropdownContent();
}
}
/**
* Set the target file for status updates
*/
public setTargetFile(file: TFile | null): void {
this.targetFile = file;
}
/**
* Updates settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
// Update dropdown if it's open
if (this.isOpen && this.dropdownElement) {
this.refreshDropdownContent();
}
}
/**
* Set callback for status changes
*/
public setOnStatusChange(callback: (statuses: string[]) => void): void {
this.onStatusChange = callback;
}
/**
* Get the current status change callback
*/
public getOnStatusChange(): (statuses: string[]) => void {
return this.onStatusChange;
}
/**
* Toggle the dropdown visibility
*/
public toggle(targetEl: HTMLElement, position?: { x: number, y: number }): void {
if (this.isOpen) {
this.close();
} else {
this.open(targetEl, position);
}
}
/**
* Open the dropdown
*/
public open(targetEl: HTMLElement, position?: { x: number, y: number }): void {
if (this.isOpen) {
this.close();
}
this.isOpen = true;
// Create dropdown element
this.dropdownElement = document.createElement('div');
this.dropdownElement.addClass('note-status-popover', 'note-status-unified-dropdown');
document.body.appendChild(this.dropdownElement);
// Fill dropdown content
this.refreshDropdownContent();
// Position the dropdown
if (position) {
this.positionAt(position.x, position.y);
} else {
this.positionRelativeTo(targetEl);
}
// Add animation class
this.dropdownElement.addClass('note-status-popover-animate-in');
// Add event listeners
setTimeout(() => {
this.clickOutsideHandler = this.handleClickOutside;
document.addEventListener('click', this.clickOutsideHandler);
document.addEventListener('keydown', this.handleEscapeKey);
}, 10);
}
/**
* Close the dropdown
*/
public close(): void {
if (!this.dropdownElement || !this.isOpen) return;
// Add exit animation
this.dropdownElement.addClass('note-status-popover-animate-out');
// Clean up event listeners
document.removeEventListener('click', this.clickOutsideHandler);
document.removeEventListener('keydown', this.handleEscapeKey);
// Remove after animation completes
setTimeout(() => {
if (this.dropdownElement) {
this.dropdownElement.remove();
this.dropdownElement = null;
this.isOpen = false;
}
}, this.animationDuration);
}
/**
* Refresh the dropdown content
*/
private refreshDropdownContent(): void {
if (!this.dropdownElement) return;
// Clear content
this.dropdownElement.empty();
// Create header
const headerEl = this.dropdownElement.createDiv({ cls: 'note-status-popover-header' });
// Title with icon
const titleEl = headerEl.createDiv({ cls: 'note-status-popover-title' });
const iconContainer = titleEl.createDiv({ cls: 'note-status-popover-icon' });
setIcon(iconContainer, 'tag');
titleEl.createSpan({ text: 'Note Status', cls: 'note-status-popover-label' });
// Current status chips
const chipsContainer = this.dropdownElement.createDiv({ cls: 'note-status-popover-chips' });
// Show 'No status' indicator if no statuses or only unknown status
if (this.currentStatuses.length === 0 ||
(this.currentStatuses.length === 1 && this.currentStatuses[0] === 'unknown')) {
chipsContainer.createDiv({
cls: 'note-status-empty-indicator',
text: 'No status assigned'
});
} else {
// Add chip for each status
this.currentStatuses.forEach(status => {
if (status === 'unknown') return; // Skip unknown status
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
if (!statusObj) return;
const chipEl = chipsContainer.createDiv({
cls: `note-status-chip status-${status}`
});
// Status icon
chipEl.createSpan({
text: statusObj.icon,
cls: 'note-status-chip-icon'
});
// Status name
chipEl.createSpan({
text: statusObj.name,
cls: 'note-status-chip-text'
});
// Remove button (only show if multiple statuses allowed)
if (this.settings.useMultipleStatuses && this.currentStatuses.length > 1) {
const removeBtn = chipEl.createDiv({
cls: 'note-status-chip-remove',
attr: {
'aria-label': `Remove ${status} status`,
'title': `Remove ${status} status`
}
});
setIcon(removeBtn, 'x');
removeBtn.addEventListener('click', async (e) => {
e.stopPropagation();
// 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 () => {
// Remove this status
await this.statusService.removeNoteStatus(status, this.targetFile);
// Get updated statuses
const updatedStatuses = this.statusService.getFileStatuses(this.targetFile);
// Update current statuses
this.currentStatuses = updatedStatuses;
// Refresh chips
this.refreshDropdownContent();
// Call status change callback
this.onStatusChange(updatedStatuses);
}, 150);
}
});
}
});
}
// Create search filter
const searchContainer = this.dropdownElement.createDiv({ cls: 'note-status-popover-search' });
const searchInput = searchContainer.createEl('input', {
type: 'text',
placeholder: 'Filter statuses...',
cls: 'note-status-popover-search-input'
});
// Create status options container
const statusOptionsContainer = this.dropdownElement.createDiv({ cls: 'note-status-options-container' });
// Get all available statuses
const allStatuses = this.statusService.getAllStatuses()
.filter(status => status.name !== 'unknown');
// Populate status options
const populateOptions = (filter = '') => {
statusOptionsContainer.empty();
const filteredStatuses = allStatuses.filter(status => !filter ||
status.name.toLowerCase().includes(filter.toLowerCase()) ||
status.icon.includes(filter));
if (filteredStatuses.length === 0) {
// Show empty state
statusOptionsContainer.createDiv({
cls: 'note-status-empty-options',
text: filter ? `No statuses match "${filter}"` : 'No statuses found'
});
return;
}
filteredStatuses.forEach(status => {
const isSelected = this.currentStatuses.includes(status.name);
const optionEl = statusOptionsContainer.createDiv({
cls: `note-status-option ${isSelected ? 'is-selected' : ''} status-${status.name}`
});
// Status icon
optionEl.createSpan({
text: status.icon,
cls: 'note-status-option-icon'
});
// Status name
optionEl.createSpan({
text: status.name,
cls: 'note-status-option-text'
});
// Check icon for selected status
if (isSelected) {
const checkIcon = optionEl.createDiv({ cls: 'note-status-option-check' });
setIcon(checkIcon, 'check');
}
// Add click handler
optionEl.addEventListener('click', async () => {
try {
// Add selection animation
optionEl.addClass('note-status-option-selecting');
// Apply status changes after brief delay for animation
setTimeout(async () => {
// Handle the case where we have a specific target file
if (this.targetFile) {
if (this.settings.useMultipleStatuses) {
await this.statusService.toggleNoteStatus(status.name, this.targetFile);
} else {
await this.statusService.updateNoteStatuses([status.name], this.targetFile);
// Close dropdown in single status mode
this.close();
}
// Get fresh status from file
const freshStatuses = this.statusService.getFileStatuses(this.targetFile);
// Update current statuses
this.currentStatuses = [...freshStatuses];
// Refresh status options if dropdown still open
if (this.isOpen && this.settings.useMultipleStatuses) {
populateOptions(searchInput.value);
}
// Call status change callback
this.onStatusChange(freshStatuses);
} else {
// This is for batch operations or when no specific file is targeted
// We'll just trigger the callback with the selected status
this.onStatusChange([status.name]);
// Usually we want to close after selection in this case
if (!this.settings.useMultipleStatuses) {
this.close();
}
}
}, 150);
} catch (error) {
console.error('Error updating status:', error);
}
});
});
};
// Initial population
populateOptions();
// Add search functionality
searchInput.addEventListener('input', () => {
populateOptions(searchInput.value);
});
// Focus search input after a short delay
setTimeout(() => {
searchInput.focus();
}, 50);
}
/**
* Position the dropdown at specific coordinates
*/
private positionAt(x: number, y: number): void {
if (!this.dropdownElement) return;
this.dropdownElement.style.position = 'fixed';
this.dropdownElement.style.zIndex = '999';
this.dropdownElement.style.left = `${x}px`;
this.dropdownElement.style.top = `${y}px`;
// Ensure dropdown doesn't go off-screen
setTimeout(() => {
if (!this.dropdownElement) return;
const rect = this.dropdownElement.getBoundingClientRect();
if (rect.right > window.innerWidth) {
this.dropdownElement.style.left = `${window.innerWidth - rect.width - 10}px`;
}
if (rect.bottom > window.innerHeight) {
this.dropdownElement.style.top = `${window.innerHeight - rect.height - 10}px`;
}
// Set max height based on viewport
const maxHeight = window.innerHeight - rect.top - 20;
this.dropdownElement.style.maxHeight = `${maxHeight}px`;
}, 0);
}
/**
* Position the dropdown relative to a target element
*/
private positionRelativeTo(targetEl: HTMLElement): void {
if (!this.dropdownElement) return;
// Reset positioning
this.dropdownElement.style.position = 'fixed';
this.dropdownElement.style.zIndex = '999';
// Get target element's position
const targetRect = targetEl.getBoundingClientRect();
// Position below the element
this.dropdownElement.style.top = `${targetRect.bottom + 5}px`;
// Align to left edge of target by default
this.dropdownElement.style.left = `${targetRect.left}px`;
// Check if dropdown would go off-screen to the right
setTimeout(() => {
if (!this.dropdownElement) return;
const rect = this.dropdownElement.getBoundingClientRect();
if (rect.right > window.innerWidth) {
// Align to right edge instead
this.dropdownElement.style.left = 'auto';
this.dropdownElement.style.right = `${window.innerWidth - targetRect.right}px`;
}
if (rect.bottom > window.innerHeight) {
// Position above the element instead
this.dropdownElement.style.top = 'auto';
this.dropdownElement.style.bottom = `${window.innerHeight - targetRect.top + 5}px`;
}
// Set max height based on viewport
const maxHeight = window.innerHeight - rect.top - 20;
this.dropdownElement.style.maxHeight = `${maxHeight}px`;
}, 0);
}
/**
* Handle click outside the dropdown
*/
private handleClickOutside(e: MouseEvent): void {
if (this.dropdownElement && !this.dropdownElement.contains(e.target as Node)) {
this.close();
}
}
/**
* Handle escape key to close dropdown
*/
private handleEscapeKey(e: KeyboardEvent): void {
if (e.key === 'Escape') {
this.close();
}
}
/**
* Dispose of resources
*/
public dispose(): void {
this.close();
}
}

View file

@ -1,301 +0,0 @@
import { MarkdownView, Editor, setIcon } from 'obsidian';
import { NoteStatusSettings } from '../models/types';
import { StatusService } from '../services/status-service';
import { StatusDropdownComponent } from './status-dropdown-component';
/**
* Enhanced status dropdown with toolbar integration
*/
export class StatusDropdown {
private app: any;
private settings: NoteStatusSettings;
private statusService: StatusService;
private currentStatuses: string[] = ['unknown'];
private toolbarButtonContainer?: HTMLElement;
private toolbarButton?: HTMLElement;
private dropdownComponent: StatusDropdownComponent;
constructor(app: any, settings: NoteStatusSettings, statusService: StatusService) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
// Initialize the dropdown component
this.dropdownComponent = new StatusDropdownComponent(app, statusService, settings);
// Configure dropdown component callbacks
this.dropdownComponent.setOnStatusChange((statuses) => {
// Update current statuses and toolbar button
this.currentStatuses = [...statuses]; // Make sure to copy the array
this.updateToolbarButton();
// Dispatch events for UI update
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses }
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
});
// Initialize toolbar button after layout is ready
this.app.workspace.onLayoutReady(() => {
this.initToolbarButton();
});
}
/**
* Initialize the toolbar button in the Obsidian ribbon
*/
private initToolbarButton(): void {
// Clear any existing button
if (this.toolbarButton) {
this.toolbarButton.remove();
this.toolbarButton = undefined;
}
if (this.toolbarButtonContainer) {
this.toolbarButtonContainer.remove();
this.toolbarButtonContainer = undefined;
}
// Wait for next tick to ensure the UI is fully rendered
setTimeout(() => {
// Try different selectors to find the toolbar
const toolbarContainer = document.querySelector('.view-header-actions') ||
document.querySelector('.view-actions') ||
document.querySelector('.workspace-ribbon.mod-right');
if (!toolbarContainer) {
console.error('Note Status: Could not find toolbar container');
return;
}
// Create button container for proper positioning
this.toolbarButtonContainer = document.createElement('div');
this.toolbarButtonContainer.addClass('note-status-toolbar-button-container');
// Create the button element
this.toolbarButton = document.createElement('button');
this.toolbarButton.addClass('note-status-toolbar-button', 'clickable-icon');
this.toolbarButton.setAttribute('aria-label', 'Note Status');
// Update initial button state
this.updateToolbarButton();
// Add click handler
this.toolbarButton.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
this.toggleStatusPopover();
});
// Add the button to the container
this.toolbarButtonContainer.appendChild(this.toolbarButton);
// Insert at a more reliable position - just prepend to the container
try {
toolbarContainer.prepend(this.toolbarButtonContainer);
} catch (error) {
console.error('Note Status: Error inserting toolbar button', error);
// Fallback - just append it
toolbarContainer.appendChild(this.toolbarButtonContainer);
}
}, 500); // Waiting 500ms to ensure the UI is ready
}
/**
* Updates the toolbar button appearance based on current statuses
*/
private updateToolbarButton(): void {
if (!this.toolbarButton) return;
// Clear existing content
this.toolbarButton.empty();
// Check if we have a valid status
const hasValidStatus = this.currentStatuses.length > 0 &&
!this.currentStatuses.every(status => status === 'unknown');
// Create badge container
const badgeContainer = document.createElement('div');
badgeContainer.addClass('note-status-toolbar-badge-container');
if (hasValidStatus) {
// Show primary status icon and indicator for multiple statuses
const primaryStatus = this.currentStatuses[0];
const statusInfo = this.statusService.getAllStatuses().find(s => s.name === primaryStatus);
if (statusInfo) {
// Primary status icon
const iconSpan = document.createElement('span');
iconSpan.addClass(`note-status-toolbar-icon`, `status-${primaryStatus}`);
iconSpan.textContent = statusInfo.icon;
badgeContainer.appendChild(iconSpan);
// Add count indicator if multiple statuses
if (this.settings.useMultipleStatuses && this.currentStatuses.length > 1) {
const countBadge = document.createElement('span');
countBadge.addClass('note-status-count-badge');
countBadge.textContent = `+${this.currentStatuses.length - 1}`;
badgeContainer.appendChild(countBadge);
}
}
} else {
// Show default status icon
const iconSpan = document.createElement('span');
iconSpan.addClass('note-status-toolbar-icon', 'status-unknown');
iconSpan.textContent = '📌'; // Default tag icon
badgeContainer.appendChild(iconSpan);
}
this.toolbarButton.appendChild(badgeContainer);
}
/**
* Updates the dropdown UI based on current statuses
*/
public update(currentStatuses: string[] | string): void {
// Normalize input to always be an array
if (typeof currentStatuses === 'string') {
this.currentStatuses = [currentStatuses];
} else {
this.currentStatuses = [...currentStatuses]; // Create a copy to ensure it's updated
}
// Update toolbar button
this.updateToolbarButton();
// Update dropdown component
this.dropdownComponent.updateStatuses(this.currentStatuses);
}
/**
* Updates settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.updateToolbarButton();
this.dropdownComponent.updateSettings(settings);
}
/**
* Toggle the status popover
*/
private toggleStatusPopover(): void {
// Get the active file first
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
// Set the target file for the dropdown component
this.dropdownComponent.setTargetFile(activeFile);
// Get current statuses
const currentStatuses = this.statusService.getFileStatuses(activeFile);
this.dropdownComponent.updateStatuses(currentStatuses);
// Create a position below the toolbar button
if (this.toolbarButton) {
const rect = this.toolbarButton.getBoundingClientRect();
const position = {
x: rect.left,
y: rect.bottom + 5
};
// Force open the dropdown with explicit position
this.dropdownComponent.open(this.toolbarButton, position);
}
}
/**
* Show status dropdown in context menu
*/
public showInContextMenu(editor: Editor, view: MarkdownView): void {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
// For editor context menu, use cursor position if possible
let position: {x: number, y: number};
try {
// Try to get cursor position
const cursor = editor.getCursor('head');
const editorPosition = editor.posToCoords(cursor);
if (editorPosition) {
position = {
x: editorPosition.left,
y: editorPosition.top + 20 // Add small offset below cursor
};
} else {
// If can't get cursor position, use editor element position
const editorEl = view.contentEl.querySelector('.cm-editor');
if (editorEl) {
const rect = editorEl.getBoundingClientRect();
position = {
x: rect.left + 100, // Offset from left
y: rect.top + 100 // Offset from top
};
} else {
// Last resort - use middle of viewport
position = {
x: window.innerWidth / 2,
y: window.innerHeight / 3
};
}
}
} catch (error) {
console.error('Error getting position for dropdown:', error);
// Fallback to center of screen
position = {
x: window.innerWidth / 2,
y: window.innerHeight / 3
};
}
// Create a dummy target element for positioning
const dummyTarget = document.createElement('div');
dummyTarget.style.position = 'fixed';
dummyTarget.style.left = `${position.x}px`;
dummyTarget.style.top = `${position.y}px`;
dummyTarget.style.zIndex = '1000';
document.body.appendChild(dummyTarget);
// Set the active file as the target for the dropdown
this.dropdownComponent.setTargetFile(activeFile);
// Get current statuses for this file
const currentStatuses = this.statusService.getFileStatuses(activeFile);
// Update dropdown with current statuses
this.dropdownComponent.updateStatuses(currentStatuses);
// Show dropdown at the calculated position
this.dropdownComponent.open(dummyTarget, position);
// Clean up dummy target after dropdown is shown
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
}, 100);
}
/**
* Render method (kept for compatibility)
*/
public render(): void {
// This is now a no-op, as the dropdown component handles everything internally
}
/**
* Remove dropdown when plugin is unloaded
*/
public unload(): void {
// Clean up dropdown component
this.dropdownComponent.dispose();
// Remove toolbar button
if (this.toolbarButtonContainer) {
this.toolbarButtonContainer.remove();
this.toolbarButtonContainer = undefined;
}
}
}