diff --git a/README.md b/README.md
index 6722635..8e8b3a3 100644
--- a/README.md
+++ b/README.md
@@ -1,182 +1,141 @@
-# Obsidian Note Status Plugin
+# Note Status for Obsidian
- 
+> 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.
+
+
+
+
+## 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.
+
+
## 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:
-
+```yaml
+---
+obsidian-note-status: ["active", "inProgress"]
+---
+```
+### Custom Hotkeys
+Set up custom hotkeys for frequently used statuses in Obsidian's Hotkeys settings.
-
+## Contributing
-
-
-
-- Status Pane: 
-
-- Dropdown Menu: 
-
-- File Explorer Icons: 
+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.
\ No newline at end of file
diff --git a/main.ts b/main.ts
index 634b9a7..18a5632 100644
--- a/main.ts
+++ b/main.ts
@@ -1,742 +1,772 @@
-import { Editor, MarkdownView, Notice, Plugin, TFile, addIcon, WorkspaceLeaf, debounce, Menu } from 'obsidian';
+import { Editor, MarkdownView, Notice, Plugin, TFile, addIcon, WorkspaceLeaf, debounce } from 'obsidian';
-// Import constants
+// Constants
import { ICONS } from './constants/icons';
import { DEFAULT_SETTINGS, DEFAULT_COLORS } from './constants/defaults';
-import { PREDEFINED_TEMPLATES } from './constants/status-templates';
-// Import interfaces and types
+// Types
import { NoteStatusSettings } from './models/types';
-// Import services
+// Services
import { StatusService } from './services/status-service';
import { StyleService } from './services/style-service';
-// Import UI components
-import { StatusBar } from './ui/status-bar';
-import { StatusDropdown } from './ui/status-dropdown';
-import { StatusDropdownComponent } from './ui/status-dropdown-component';
-import { StatusPaneView } from './ui/status-pane-view';
-import { ExplorerIntegration } from './ui/explorer';
-import { BatchStatusModal } from './ui/modals';
-import { StatusContextMenu } from './ui/context-menus';
+// UI Components
+import { StatusBar } from './ui/components/status-bar';
+import { StatusDropdown } from './ui/components/status-dropdown';
+import { StatusPaneView } from './ui/components/status-pane-view';
+import { ExplorerIntegration } from './ui/integrations/explorer-integration';
+import { BatchStatusModal } from './ui/modals/batch-status-modal';
+import { StatusContextMenu } from './ui/menus/status-context-menu';
-// Import settings
+// Settings
import { NoteStatusSettingTab } from './settings/settings-tab';
-// Plugin version
+// Plugin version (centralized for easier updates)
const PLUGIN_VERSION = '1.5.0';
/**
- * Main plugin class with performance optimizations and enhanced error handling
+ * Main plugin class for Note Status functionality
*/
export default class NoteStatus extends Plugin {
- settings: NoteStatusSettings;
- statusService: StatusService;
- styleService: StyleService;
- statusBar: StatusBar;
- statusDropdown: StatusDropdown;
- explorerIntegration: ExplorerIntegration;
- statusContextMenu: StatusContextMenu;
- private statusPaneLeaf: WorkspaceLeaf | null = null;
- private lastActiveFile: TFile | null = null;
-
- // Debounced methods for better performance
- private debouncedCheckNoteStatus = debounce(
- () => this.checkNoteStatus(),
- 100,
- true
- );
-
- private debouncedUpdateExplorer = debounce(
- () => this.explorerIntegration?.updateAllFileExplorerIcons(),
- 150
- );
-
- private debouncedUpdateStatusPane = debounce(
- () => this.updateStatusPane(),
- 200
- );
-
- // Flag to prevent excessive notifications
- private hasShownErrorNotification = false;
+ settings: NoteStatusSettings;
+ statusService: StatusService;
+ styleService: StyleService;
+ statusBar: StatusBar;
+ statusDropdown: StatusDropdown;
+ explorerIntegration: ExplorerIntegration;
+ statusContextMenu: StatusContextMenu;
+ private statusPaneLeaf: WorkspaceLeaf | null = null;
+ private lastActiveFile: TFile | null = null;
+
+ // Debounced methods for better performance
+ private debouncedCheckNoteStatus = debounce(
+ () => this.checkNoteStatus(),
+ 100,
+ true
+ );
+
+ private debouncedUpdateExplorer = debounce(
+ () => this.explorerIntegration?.updateAllFileExplorerIcons(),
+ 150
+ );
+
+ private debouncedUpdateStatusPane = debounce(
+ () => this.updateStatusPane(),
+ 200
+ );
+
+ private hasShownErrorNotification = false;
- async onload() {
- console.log(`Loading Note Status plugin v${PLUGIN_VERSION}`);
-
- try {
- // Register custom icons
- this.registerIcons();
+ async onload() {
+ console.log(`Loading Note Status plugin v${PLUGIN_VERSION}`);
+
+ try {
+ await this.initialize();
+ } catch (error) {
+ console.error('Error loading Note Status plugin:', error);
+ new Notice('Error loading Note Status plugin. Check console for details.');
+ }
+ }
+
+ /**
+ * Initialize the plugin components
+ */
+ private async initialize(): Promise {
+ // Register custom icons
+ this.registerIcons();
- // Load settings
- await this.loadSettings();
+ // Load settings
+ await this.loadSettings();
- // Initialize services
- this.initializeServices();
+ // Initialize services
+ this.initializeServices();
- // Initialize UI components
- this.initializeUI();
+ // Initialize UI components
+ this.initializeUI();
- // Register status pane view
- this.registerView('status-pane', (leaf) => {
- this.statusPaneLeaf = leaf;
- return new StatusPaneView(leaf, this);
- });
+ // Register views, commands, and events
+ this.registerViews();
+ this.registerCommands();
+ this.registerMenuHandlers();
+ this.registerEvents();
+
+ // Set up custom events
+ this.setupCustomEvents();
- // Add ribbon icon
- this.addRibbonIcon('status-pane', 'Open Status Pane', () => {
- this.openStatusPane();
- });
+ // Add settings tab
+ this.addSettingTab(new NoteStatusSettingTab(this.app, this));
- // Register commands
- this.registerCommands();
+ // Initialize UI on layout ready
+ this.app.workspace.onLayoutReady(async () => {
+ await this.onLayoutReady();
+ });
+ }
+
+ /**
+ * Handle layout ready event for final initialization steps
+ */
+ private async onLayoutReady(): Promise {
+ // Small delay to ensure everything is loaded
+ await new Promise(resolve => setTimeout(resolve, 200));
+ this.checkNoteStatus();
+ this.statusDropdown.update(this.getCurrentStatuses());
+
+ this.explorerIntegration.updateAllFileExplorerIcons();
+
+ // Add additional retry with delay to ensure icons are updated
+ setTimeout(() => {
+ console.log('Note Status: Retry updating file explorer icons');
+ this.explorerIntegration.updateAllFileExplorerIcons();
+ }, 2000);
+ }
+
+ /**
+ * Register custom icons
+ */
+ private registerIcons(): void {
+ Object.entries(ICONS).forEach(([name, svg]) => {
+ if (name === 'statusPane') {
+ addIcon('status-pane', svg);
+ } else {
+ addIcon(`note-status-${name}`, svg);
+ }
+ });
+ }
+
+ /**
+ * Register views for status pane
+ */
+ private registerViews(): void {
+ this.registerView('status-pane', (leaf) => {
+ this.statusPaneLeaf = leaf;
+ return new StatusPaneView(leaf, this);
+ });
- // Register file menu and context menu handlers
- this.registerMenuHandlers();
+ // Add ribbon icon
+ this.addRibbonIcon('status-pane', 'Open Status Pane', () => {
+ this.openStatusPane();
+ });
+ }
+
+ /**
+ * Initialize plugin services
+ */
+ private initializeServices(): void {
+ this.statusService = new StatusService(this.app, this.settings);
+ this.styleService = new StyleService(this.settings);
+ }
+
+ /**
+ * Initialize UI components
+ */
+ private initializeUI(): void {
+ this.statusBar = new StatusBar(this.addStatusBarItem(), this.settings, this.statusService);
+ this.statusDropdown = new StatusDropdown(this.app, this.settings, this.statusService);
+ this.explorerIntegration = new ExplorerIntegration(this.app, this.settings, this.statusService);
+ this.statusContextMenu = new StatusContextMenu(this.app, this.settings, this.statusService);
+ }
- // Register workspace and vault events
- this.registerEvents();
+ /**
+ * Set up custom events for status changes and UI updates
+ */
+ private setupCustomEvents(): void {
+ // Listen for settings changes
+ window.addEventListener('note-status:settings-changed', async () => {
+ await this.saveSettings();
+ });
- // Initialize UI on layout ready
- this.app.workspace.onLayoutReady(async () => {
- // Small delay to ensure everything is loaded
- await new Promise(resolve => setTimeout(resolve, 200));
- this.checkNoteStatus();
- this.statusDropdown.update(this.getCurrentStatuses());
-
- this.explorerIntegration.updateAllFileExplorerIcons();
-
- // Add additional retry with delay to ensure icons are updated
- setTimeout(() => {
- console.log('Note Status: Retry updating file explorer icons');
- this.explorerIntegration.updateAllFileExplorerIcons();
- }, 2000);
- });
+ // Listen for force refresh
+ window.addEventListener('note-status:force-refresh', () => {
+ try {
+ this.forceRefreshUI();
+ } catch (error) {
+ console.error('Error handling force refresh event:', error);
+ }
+ });
- // Add settings tab
- this.addSettingTab(new NoteStatusSettingTab(this.app, this));
+ // Listen for status changes
+ window.addEventListener('note-status:status-changed', (e: any) => {
+ try {
+ const statuses = e.detail?.statuses || ['unknown'];
+ this.statusBar.update(statuses);
+ this.statusDropdown.update(statuses);
+
+ // Update explorer icons for the active file
+ const activeFile = this.app.workspace.getActiveFile();
+ if (activeFile) {
+ this.explorerIntegration.updateFileExplorerIcons(activeFile);
+ }
+
+ setTimeout(() => this.statusDropdown.render(), 50);
+ } catch (error) {
+ console.error('Error handling status change event:', error);
+ }
+ });
- // Set up custom events
- this.setupCustomEvents();
- } catch (error) {
- console.error('Error loading Note Status plugin:', error);
- new Notice('Error loading Note Status plugin. Check console for details.');
- }
- }
-
- /**
- * Register custom icons
- */
- private registerIcons(): void {
- Object.entries(ICONS).forEach(([name, svg]) => {
- if (name === 'statusPane') {
- addIcon('status-pane', svg);
- } else {
- addIcon(`note-status-${name}`, svg);
- }
- });
- }
-
- /**
- * Initialize plugin services
- */
- private initializeServices(): void {
- this.statusService = new StatusService(this.app, this.settings);
- this.styleService = new StyleService(this.settings);
- }
-
- /**
- * Initialize UI components
- */
- private initializeUI(): void {
- this.statusBar = new StatusBar(this.addStatusBarItem(), this.settings, this.statusService);
- this.statusDropdown = new StatusDropdown(this.app, this.settings, this.statusService);
- this.explorerIntegration = new ExplorerIntegration(this.app, this.settings, this.statusService);
- this.statusContextMenu = new StatusContextMenu(this.app, this.settings, this.statusService);
+ // Listen for refresh dropdown
+ window.addEventListener('note-status:refresh-dropdown', () => {
+ try {
+ const currentStatuses = this.getCurrentStatuses();
+ this.statusDropdown.update(currentStatuses);
+ } catch (error) {
+ console.error('Error refreshing dropdown:', error);
+ }
+ });
- // Register event to update toolbar button when plugin is loaded
- this.app.workspace.onLayoutReady(() => {
- this.statusDropdown.update(this.getCurrentStatuses());
- });
- }
+ // Listen for UI refresh
+ window.addEventListener('note-status:refresh-ui', () => {
+ try {
+ this.debouncedCheckNoteStatus();
+ this.debouncedUpdateExplorer();
+ this.debouncedUpdateStatusPane();
+ } catch (error) {
+ console.error('Error refreshing UI:', error);
+ }
+ });
+
+ // Listen for batch modal opening
+ window.addEventListener('note-status:open-batch-modal', () => {
+ try {
+ this.showBatchStatusModal();
+ } catch (error) {
+ console.error('Error opening batch modal:', error);
+ }
+ });
+ }
- /**
- * Set up custom events for status changes and UI updates
- */
- private setupCustomEvents() {
- // Listen for settings changes
- window.addEventListener('note-status:settings-changed', async () => {
- await this.saveSettings();
- });
+ /**
+ * Register plugin commands
+ */
+ private registerCommands(): void {
+ // Refresh status command
+ this.addCommand({
+ id: 'refresh-status',
+ name: 'Refresh Status',
+ callback: () => {
+ this.checkNoteStatus();
+ new Notice('Note status refreshed!');
+ }
+ });
- // Listen for force refresh
- window.addEventListener('note-status:force-refresh', () => {
- try {
- this.forceRefreshUI();
- } catch (error) {
- console.error('Error handling force refresh event:', error);
- }
- });
+ this.addCommand({
+ id: 'force-refresh-ui',
+ name: 'Force Refresh UI',
+ callback: () => this.forceRefreshUI()
+ });
- // Listen for status changes
- window.addEventListener('note-status:status-changed', (e: any) => {
- try {
- const statuses = e.detail?.statuses || ['unknown'];
- this.statusBar.update(statuses);
- this.statusDropdown.update(statuses);
-
- // Update explorer icons for the active file
- const activeFile = this.app.workspace.getActiveFile();
- if (activeFile) {
- this.explorerIntegration.updateFileExplorerIcons(activeFile);
- }
-
- // Small delay to ensure all data is updated first
- setTimeout(() => {
- this.statusDropdown.render();
- }, 50);
- } catch (error) {
- console.error('Error handling status change event:', error);
- }
- });
+ // Batch update status command
+ this.addCommand({
+ id: 'batch-update-status',
+ name: 'Batch Update Status',
+ callback: () => this.showBatchStatusModal()
+ });
- // Listen for refresh dropdown
- window.addEventListener('note-status:refresh-dropdown', () => {
- try {
- const currentStatuses = this.getCurrentStatuses();
- this.statusDropdown.update(currentStatuses);
- } catch (error) {
- console.error('Error refreshing dropdown:', error);
- }
- });
+ // Insert status metadata command
+ this.addCommand({
+ id: 'insert-status-metadata',
+ name: 'Insert Status Metadata',
+ editorCallback: (editor: Editor, view: MarkdownView) => {
+ this.statusService.insertStatusMetadataInEditor(editor);
+ new Notice('Status metadata inserted');
+ }
+ });
- // Listen for UI refresh
- window.addEventListener('note-status:refresh-ui', () => {
- try {
- this.debouncedCheckNoteStatus();
- this.debouncedUpdateExplorer();
- this.debouncedUpdateStatusPane();
- } catch (error) {
- console.error('Error refreshing UI:', error);
- }
- });
-
- // Listen for batch modal opening
- window.addEventListener('note-status:open-batch-modal', () => {
- try {
- this.showBatchStatusModal();
- } catch (error) {
- console.error('Error opening batch modal:', error);
- }
- });
- }
+ // Open status pane command
+ this.addCommand({
+ id: 'open-status-pane',
+ name: 'Open Status Pane',
+ callback: () => this.openStatusPane()
+ });
+
+ // Toggle status dropdown command
+ this.addCommand({
+ id: 'toggle-status-dropdown',
+ name: 'Toggle Status Dropdown',
+ callback: () => {
+ this.settings.showStatusDropdown = !this.settings.showStatusDropdown;
+ this.saveSettings();
+
+ if (this.settings.showStatusDropdown) {
+ this.statusDropdown.update(this.getCurrentStatuses());
+ new Notice('Status dropdown shown');
+ } else {
+ new Notice('Status dropdown hidden');
+ }
+ }
+ });
+
+ // Add status to note command
+ this.addCommand({
+ id: 'add-status-to-note',
+ name: 'Add Status to Current Note',
+ callback: () => this.showAddStatusToNoteMenu()
+ });
+ }
+
+ /**
+ * Show menu for adding a status to the current note
+ */
+ private showAddStatusToNoteMenu(): void {
+ const activeFile = this.app.workspace.getActiveFile();
+ if (!activeFile || activeFile.extension !== 'md') {
+ new Notice('No markdown file is active');
+ return;
+ }
+
+ this.statusContextMenu.showForFile(activeFile, new MouseEvent('click'));
+ }
- /**
- * Register plugin commands
- */
- private registerCommands() {
- // Refresh status command
- this.addCommand({
- id: 'refresh-status',
- name: 'Refresh Status',
- callback: () => {
- this.checkNoteStatus();
- new Notice('Note status refreshed!');
- }
- });
+ /**
+ * Register menu handlers
+ */
+ private registerMenuHandlers(): void {
+ // File explorer context menu
+ this.registerEvent(
+ this.app.workspace.on('file-menu', (menu, file, source) => {
+ if (source === 'file-explorer-context-menu' && file instanceof TFile && file.extension === 'md') {
+ menu.addItem((item) =>
+ item
+ .setTitle('Change Status')
+ .setIcon('tag')
+ .onClick(() => {
+ const selectedFiles = this.explorerIntegration.getSelectedFiles();
+ if (selectedFiles.length > 1) {
+ this.statusContextMenu.showForFiles(selectedFiles);
+ } else {
+ this.statusContextMenu.showForFiles([file]);
+ }
+ })
+ );
+ }
+ })
+ );
- this.addCommand({
- id: 'force-refresh-ui',
- name: 'Force Refresh UI',
- callback: () => this.forceRefreshUI()
- });
+ // Multiple files selection menu
+ this.registerEvent(
+ this.app.workspace.on('files-menu', (menu, files) => {
+ const mdFiles = files.filter(file => file instanceof TFile && file.extension === 'md') as TFile[];
+ if (mdFiles.length > 0) {
+ menu.addItem((item) =>
+ item
+ .setTitle('Change Status')
+ .setIcon('tag')
+ .onClick(() => {
+ this.statusContextMenu.showForFiles(mdFiles);
+ })
+ );
+ }
+ })
+ );
- // Batch update status command
- this.addCommand({
- id: 'batch-update-status',
- name: 'Batch Update Status',
- callback: () => this.showBatchStatusModal()
- });
+ // Editor context menu
+ this.registerEvent(
+ this.app.workspace.on('editor-menu', (menu, editor, view) => {
+ if (view instanceof MarkdownView) {
+ menu.addItem((item) =>
+ item
+ .setTitle('Change Note Status')
+ .setIcon('tag')
+ .onClick(() => this.statusDropdown.showInContextMenu(editor, view))
+ );
+ }
+ })
+ );
+ }
- // Insert status metadata command
- this.addCommand({
- id: 'insert-status-metadata',
- name: 'Insert Status Metadata',
- editorCallback: (editor: Editor, view: MarkdownView) => {
- this.statusService.insertStatusMetadataInEditor(editor);
- new Notice('Status metadata inserted');
- }
- });
+ /**
+ * Register workspace and vault events
+ */
+ private registerEvents(): void {
+ // File open event with optimization to avoid redundant updates
+ this.registerEvent(this.app.workspace.on('file-open', (file) => {
+ if (file instanceof TFile) {
+ // Only update if the file actually changed
+ if (this.lastActiveFile?.path !== file.path) {
+ this.lastActiveFile = file;
+ this.checkNoteStatus();
+ this.statusDropdown.update(this.getCurrentStatuses());
+ }
+ } else {
+ this.lastActiveFile = null;
+ this.statusBar.update(['unknown']);
+ this.statusDropdown.update(['unknown']);
+ }
+ }));
- // Open status pane command
- this.addCommand({
- id: 'open-status-pane',
- name: 'Open Status Pane',
- callback: () => this.openStatusPane()
- });
-
- // Toggle status dropdown command
- this.addCommand({
- id: 'toggle-status-dropdown',
- name: 'Toggle Status Dropdown',
- callback: () => {
- this.settings.showStatusDropdown = !this.settings.showStatusDropdown;
- this.saveSettings();
-
- if (this.settings.showStatusDropdown) {
- this.statusDropdown.update(this.getCurrentStatuses());
- new Notice('Status dropdown shown');
- } else {
- new Notice('Status dropdown hidden');
- }
- }
- });
-
- // Add status to note command
- this.addCommand({
- id: 'add-status-to-note',
- name: 'Add Status to Current Note',
- callback: () => {
- const activeFile = this.app.workspace.getActiveFile();
- if (!activeFile || activeFile.extension !== 'md') {
- new Notice('No markdown file is active');
- return;
- }
-
- const menu = new Menu();
- const allStatuses = this.statusService.getAllStatuses()
- .filter(status => status.name !== 'unknown');
-
- allStatuses.forEach(status => {
- menu.addItem(item =>
- item.setTitle(`${status.icon} ${status.name}`)
- .onClick(async () => {
- await this.statusService.addNoteStatus(status.name);
- new Notice(`Added "${status.name}" status to note`);
- })
- );
- });
-
- menu.showAtMouseEvent(new MouseEvent('click'));
- }
- });
- }
+ // Editor change event - debounced to avoid performance issues
+ this.registerEvent(this.app.workspace.on('editor-change', () => {
+ this.debouncedCheckNoteStatus();
+ }));
- /**
- * Register menu handlers
- */
- private registerMenuHandlers() {
- // File explorer context menu
- this.registerEvent(
- this.app.workspace.on('file-menu', (menu, file, source) => {
- if (source === 'file-explorer-context-menu' && file instanceof TFile && file.extension === 'md') {
- menu.addItem((item) =>
- item
- .setTitle('Change Status')
- .setIcon('tag')
- .onClick(() => {
- const selectedFiles = this.explorerIntegration.getSelectedFiles();
- if (selectedFiles.length > 1) {
- this.statusContextMenu.showForFiles(selectedFiles);
- } else {
- this.statusContextMenu.showForFiles([file]);
- }
- })
- );
- }
- })
- );
+ // Active leaf change event - optimized to avoid redundant updates
+ this.registerEvent(this.app.workspace.on('active-leaf-change', () => {
+ const activeFile = this.app.workspace.getActiveFile();
+
+ // Only update if the file actually changed
+ if (this.lastActiveFile?.path !== activeFile?.path) {
+ this.lastActiveFile = activeFile;
+ this.statusDropdown.update(this.getCurrentStatuses());
+ this.debouncedUpdateStatusPane();
+ }
+ }));
- // Multiple files selection menu
- this.registerEvent(
- this.app.workspace.on('files-menu', (menu, files) => {
- const mdFiles = files.filter(file => file instanceof TFile && file.extension === 'md') as TFile[];
- if (mdFiles.length > 0) {
- menu.addItem((item) =>
- item
- .setTitle('Change Status')
- .setIcon('tag')
- .onClick(() => {
- this.statusContextMenu.showForFiles(mdFiles);
- })
- );
- }
- })
- );
+ this.registerFileEvents();
+ this.registerMetadataEvents();
+
+ // Layout change event - ensure status pane is properly rendered
+ this.registerEvent(this.app.workspace.on('layout-change', () => {
+ this.debouncedUpdateStatusPane();
+ }));
+ }
+
+ /**
+ * Register file modification events
+ */
+ private registerFileEvents(): void {
+ // File modification events with optimization
+ this.registerEvent(this.app.vault.on('modify', (file) => {
+ if (file instanceof TFile && file.extension === 'md') {
+ // Only update UI for the modified file
+ this.explorerIntegration.updateFileExplorerIcons(file);
+
+ // If this is the active file, also update other UI elements
+ if (this.app.workspace.getActiveFile()?.path === file.path) {
+ this.checkNoteStatus();
+ this.statusDropdown.update(this.getCurrentStatuses());
+ }
+
+ // Update the status pane but debounced
+ this.debouncedUpdateStatusPane();
+ }
+ }));
- // Editor context menu
- this.registerEvent(
- this.app.workspace.on('editor-menu', (menu, editor, view) => {
- if (view instanceof MarkdownView) {
- menu.addItem((item) =>
- item
- .setTitle('Change Note Status')
- .setIcon('tag')
- .onClick(() => this.statusDropdown.showInContextMenu(editor, view))
- );
- }
- })
- );
- }
+ // File creation, deletion, and rename events
+ this.registerEvent(this.app.vault.on('create', (file) => {
+ if (file instanceof TFile && file.extension === 'md') {
+ this.explorerIntegration.updateFileExplorerIcons(file);
+ this.debouncedUpdateStatusPane();
+ }
+ }));
- /**
- * Register workspace and vault events
- */
- private registerEvents() {
- // File open event with optimization to avoid redundant updates
- this.registerEvent(this.app.workspace.on('file-open', (file) => {
- if (file instanceof TFile) {
- // Only update if the file actually changed
- if (this.lastActiveFile?.path !== file.path) {
- this.lastActiveFile = file;
- this.checkNoteStatus();
- this.statusDropdown.update(this.getCurrentStatuses());
- }
- } else {
- this.lastActiveFile = null;
- this.statusBar.update(['unknown']);
- this.statusDropdown.update(['unknown']); // Add this line to update toolbar when no file is open
- }
- }));
+ this.registerEvent(this.app.vault.on('delete', () => {
+ this.debouncedUpdateExplorer();
+ this.debouncedUpdateStatusPane();
+ }));
- // Editor change event - debounced to avoid performance issues
- this.registerEvent(this.app.workspace.on('editor-change', () => {
- this.debouncedCheckNoteStatus();
- }));
+ this.registerEvent(this.app.vault.on('rename', (file) => {
+ if (file instanceof TFile && file.extension === 'md') {
+ this.explorerIntegration.updateAllFileExplorerIcons();
+ this.debouncedUpdateStatusPane();
+ }
+ }));
+ }
+
+ /**
+ * Register metadata cache events
+ */
+ private registerMetadataEvents(): void {
+ // Metadata change events
+ this.registerEvent(this.app.metadataCache.on('changed', (file) => {
+ if (file instanceof TFile && file.extension === 'md') {
+ this.explorerIntegration.updateFileExplorerIcons(file);
+
+ // Update other UI elements if this is the active file
+ if (this.app.workspace.getActiveFile()?.path === file.path) {
+ this.checkNoteStatus();
+ this.statusDropdown.update(this.getCurrentStatuses());
+ }
+
+ this.debouncedUpdateStatusPane();
+ }
+ }));
- // Active leaf change event - optimized to avoid redundant updates
- this.registerEvent(this.app.workspace.on('active-leaf-change', () => {
- const activeFile = this.app.workspace.getActiveFile();
-
- // Only update if the file actually changed
- if (this.lastActiveFile?.path !== activeFile?.path) {
- this.lastActiveFile = activeFile;
- this.statusDropdown.update(this.getCurrentStatuses());
- this.debouncedUpdateStatusPane();
- }
- }));
+ // Metadata resolved event - when all files are indexed
+ this.registerEvent(
+ this.app.metadataCache.on('resolved', () => {
+ // When metadata cache is fully resolved, update all icons
+ console.log('Note Status: Metadata cache resolved, updating all icons');
+ setTimeout(() => this.explorerIntegration.updateAllFileExplorerIcons(), 500);
+ })
+ );
+ }
- // File modification events with optimization
- this.registerEvent(this.app.vault.on('modify', (file) => {
- if (file instanceof TFile && file.extension === 'md') {
- // Only update UI for the modified file
- this.explorerIntegration.updateFileExplorerIcons(file);
-
- // If this is the active file, also update other UI elements
- if (this.app.workspace.getActiveFile()?.path === file.path) {
- this.checkNoteStatus();
- this.statusDropdown.update(this.getCurrentStatuses());
- }
-
- // Update the status pane but debounced
- this.debouncedUpdateStatusPane();
- }
- }));
+ /**
+ * Check and update the status display for the active file
+ */
+ checkNoteStatus(): void {
+ try {
+ const activeFile = this.app.workspace.getActiveFile();
+ if (!activeFile || activeFile.extension !== 'md') {
+ this.statusBar.update(['unknown']);
+ this.statusDropdown.update(['unknown']);
+ return;
+ }
+
+ const statuses = this.statusService.getFileStatuses(activeFile);
+ this.statusBar.update(statuses);
+ this.statusDropdown.update(statuses);
+ } catch (error) {
+ console.error('Error checking note status:', error);
+ if (!this.hasShownErrorNotification) {
+ new Notice('Error checking note status. Check console for details.');
+ this.hasShownErrorNotification = true;
+ setTimeout(() => { this.hasShownErrorNotification = false; }, 10000);
+ }
+ }
+ }
- // File creation, deletion, and rename events
- this.registerEvent(this.app.vault.on('create', (file) => {
- if (file instanceof TFile && file.extension === 'md') {
- this.explorerIntegration.updateFileExplorerIcons(file);
- this.debouncedUpdateStatusPane();
- }
- }));
+ /**
+ * Get the current statuses for the active file
+ * Always returns an array of status names
+ */
+ getCurrentStatuses(): string[] {
+ try {
+ const activeFile = this.app.workspace.getActiveFile();
+ if (!activeFile || activeFile.extension !== 'md') {
+ return ['unknown'];
+ }
+ return this.statusService.getFileStatuses(activeFile);
+ } catch (error) {
+ console.error('Error getting current statuses:', error);
+ return ['unknown'];
+ }
+ }
- this.registerEvent(this.app.vault.on('delete', () => {
- this.debouncedUpdateExplorer();
- this.debouncedUpdateStatusPane();
- }));
+ /**
+ * Open the status pane
+ */
+ async openStatusPane(): Promise {
+ try {
+ const existing = this.app.workspace.getLeavesOfType('status-pane')[0];
+ if (existing) {
+ this.app.workspace.setActiveLeaf(existing);
+ await this.updateStatusPane();
+ } else {
+ const leaf = this.app.workspace.getLeftLeaf(false);
+ if (leaf) {
+ await leaf.setViewState({ type: 'status-pane', active: true });
+ this.statusPaneLeaf = leaf;
+ }
+ }
+ } catch (error) {
+ console.error('Error opening status pane:', error);
+ new Notice('Error opening status pane. Check console for details.');
+ }
+ }
- this.registerEvent(this.app.vault.on('rename', (file) => {
- if (file instanceof TFile && file.extension === 'md') {
- this.explorerIntegration.updateAllFileExplorerIcons();
- this.debouncedUpdateStatusPane();
- }
- }));
+ /**
+ * Show the batch status modal
+ */
+ showBatchStatusModal(): void {
+ try {
+ new BatchStatusModal(this.app, this.settings, this.statusService).open();
+ } catch (error) {
+ console.error('Error showing batch status modal:', error);
+ new Notice('Error showing batch status modal. Check console for details.');
+ }
+ }
- // Metadata change events
- this.registerEvent(this.app.metadataCache.on('changed', (file) => {
- if (file instanceof TFile && file.extension === 'md') {
- this.explorerIntegration.updateFileExplorerIcons(file);
-
- // Update other UI elements if this is the active file
- if (this.app.workspace.getActiveFile()?.path === file.path) {
- this.checkNoteStatus();
- this.statusDropdown.update(this.getCurrentStatuses());
- }
-
- this.debouncedUpdateStatusPane();
- }
- }));
+ /**
+ * Show status context menu
+ */
+ showStatusContextMenu(files: TFile[]): void {
+ try {
+ this.statusContextMenu.showForFiles(files);
+ } catch (error) {
+ console.error('Error showing status context menu:', error);
+ new Notice('Error showing status context menu. Check console for details.');
+ }
+ }
- // Metadata resolved event - when all files are indexed
- this.registerEvent(
- this.app.metadataCache.on('resolved', () => {
- // When metadata cache is fully resolved, update all icons
- console.log('Note Status: Metadata cache resolved, updating all icons');
- setTimeout(() => this.explorerIntegration.updateAllFileExplorerIcons(), 500);
- })
- );
-
- // Layout change event - ensure status pane is properly rendered
- this.registerEvent(this.app.workspace.on('layout-change', () => {
- this.debouncedUpdateStatusPane();
- }));
- }
+ /**
+ * Update the status pane
+ */
+ async updateStatusPane(): Promise {
+ try {
+ if (this.statusPaneLeaf && this.statusPaneLeaf.view instanceof StatusPaneView) {
+ const searchQuery = (this.statusPaneLeaf.view as StatusPaneView).searchInput?.value.toLowerCase() || '';
+ await (this.statusPaneLeaf.view as StatusPaneView).renderGroups(searchQuery);
+ }
+ } catch (error) {
+ console.error('Error updating status pane:', error);
+ }
+ }
- /**
- * Check and update the status display for the active file
- */
- checkNoteStatus() {
- try {
- const activeFile = this.app.workspace.getActiveFile();
- if (!activeFile || activeFile.extension !== 'md') {
- this.statusBar.update(['unknown']);
- this.statusDropdown.update(['unknown']);
- return;
- }
-
- const statuses = this.statusService.getFileStatuses(activeFile);
- this.statusBar.update(statuses);
- this.statusDropdown.update(statuses);
- } catch (error) {
- console.error('Error checking note status:', error);
- if (!this.hasShownErrorNotification) {
- new Notice('Error checking note status. Check console for details.');
- this.hasShownErrorNotification = true;
- setTimeout(() => { this.hasShownErrorNotification = false; }, 10000);
- }
- }
- }
+ /**
+ * Reset default colors
+ */
+ async resetDefaultColors(): Promise {
+ try {
+ // Reset colors for default statuses
+ const defaultStatuses = ['active', 'onHold', 'completed', 'dropped', 'unknown'];
- /**
- * Get the current statuses for the active file
- * Always returns an array of status names
- */
- getCurrentStatuses(): string[] {
- try {
- const activeFile = this.app.workspace.getActiveFile();
- if (!activeFile || activeFile.extension !== 'md') {
- return ['unknown'];
- }
- return this.statusService.getFileStatuses(activeFile);
- } catch (error) {
- console.error('Error getting current statuses:', error);
- return ['unknown'];
- }
- }
+ for (const status of defaultStatuses) {
+ if (this.settings.customStatuses.some(s => s.name === status)) {
+ this.settings.statusColors[status] = DEFAULT_COLORS[status];
+ }
+ }
+
+ // Handle template status colors
+ this.resetTemplateColors();
- /**
- * Open the status pane
- */
- async openStatusPane() {
- try {
- const existing = this.app.workspace.getLeavesOfType('status-pane')[0];
- if (existing) {
- this.app.workspace.setActiveLeaf(existing);
- await this.updateStatusPane();
- } else {
- const leaf = this.app.workspace.getLeftLeaf(false);
- if (leaf) {
- await leaf.setViewState({ type: 'status-pane', active: true });
- this.statusPaneLeaf = leaf;
- }
- }
- } catch (error) {
- console.error('Error opening status pane:', error);
- new Notice('Error opening status pane. Check console for details.');
- }
- }
+ await this.saveSettings();
+ new Notice('Default colors have been restored');
+ } catch (error) {
+ console.error('Error resetting default colors:', error);
+ new Notice('Error resetting default colors. Check console for details.');
+ }
+ }
+
+ /**
+ * Reset colors for template statuses
+ */
+ private resetTemplateColors(): void {
+ if (!this.settings.useCustomStatusesOnly) {
+ const templateStatuses = this.statusService.getTemplateStatuses();
+ for (const status of templateStatuses) {
+ if (status.color) {
+ this.settings.statusColors[status.name] = status.color;
+ }
+ }
+ }
+ }
- /**
- * Show the batch status modal
- */
- showBatchStatusModal() {
- try {
- new BatchStatusModal(this.app, this.settings, this.statusService).open();
- } catch (error) {
- console.error('Error showing batch status modal:', error);
- new Notice('Error showing batch status modal. Check console for details.');
- }
- }
+ /**
+ * Load settings with error handling
+ */
+ async loadSettings(): Promise {
+ try {
+ const loadedData = await this.loadData();
+ this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedData);
+ this.initializeDefaultColors();
+ } catch (error) {
+ console.error('Error loading settings:', error);
+ new Notice('Error loading settings. Using defaults. Check console for details.');
+ this.settings = Object.assign({}, DEFAULT_SETTINGS);
+ this.initializeDefaultColors();
+ }
+ }
- /**
- * Show status context menu
- */
- showStatusContextMenu(files: TFile[]) {
- try {
- this.statusContextMenu.showForFiles(files);
- } catch (error) {
- console.error('Error showing status context menu:', error);
- new Notice('Error showing status context menu. Check console for details.');
- }
- }
+ /**
+ * Initialize default colors
+ */
+ private initializeDefaultColors(): void {
+ // Ensure default colors are set for all statuses
+ for (const [status, color] of Object.entries(DEFAULT_COLORS)) {
+ if (!this.settings.statusColors[status]) {
+ this.settings.statusColors[status] = color;
+ }
+ }
- /**
- * Update the status pane
- */
- async updateStatusPane() {
- try {
- if (this.statusPaneLeaf && this.statusPaneLeaf.view instanceof StatusPaneView) {
- const searchQuery = (this.statusPaneLeaf.view as StatusPaneView).searchInput?.value.toLowerCase() || '';
- await (this.statusPaneLeaf.view as StatusPaneView).renderGroups(searchQuery);
- }
- } catch (error) {
- console.error('Error updating status pane:', error);
- }
- }
+ // Also initialize colors from enabled templates if not present
+ this.initializeTemplateColors();
+ }
+
+ /**
+ * Initialize colors from templates
+ */
+ private initializeTemplateColors(): void {
+ if (!this.settings.useCustomStatusesOnly) {
+ const templateStatuses = this.statusService.getTemplateStatuses();
+ for (const status of templateStatuses) {
+ if (status.color && !this.settings.statusColors[status.name]) {
+ this.settings.statusColors[status.name] = status.color;
+ }
+ }
+ }
+ }
- /**
- * Reset default colors
- */
- async resetDefaultColors() {
- try {
- // Reset colors for default statuses
- const defaultStatuses = ['active', 'onHold', 'completed', 'dropped', 'unknown'];
+ /**
+ * Save settings with error handling
+ */
+ async saveSettings(): Promise {
+ try {
+ await this.saveData(this.settings);
+ this.updateComponentSettings();
+ } catch (error) {
+ console.error('Error saving settings:', error);
+ new Notice('Error saving settings. Check console for details.');
+ }
+ }
+
+ /**
+ * Update all components with new settings
+ */
+ private updateComponentSettings(): void {
+ // Update services with new settings
+ this.statusService.updateSettings(this.settings);
+ this.styleService.updateSettings(this.settings);
- for (const status of defaultStatuses) {
- if (this.settings.customStatuses.some(s => s.name === status)) {
- this.settings.statusColors[status] = DEFAULT_COLORS[status];
- }
- }
+ // Update UI components with new settings
+ this.statusBar.updateSettings(this.settings);
+ this.statusDropdown.updateSettings(this.settings);
+ this.explorerIntegration.updateSettings(this.settings);
+ this.statusContextMenu.updateSettings(this.settings);
- // Also reset template status colors
- if (!this.settings.useCustomStatusesOnly) {
- for (const templateId of this.settings.enabledTemplates) {
- const template = PREDEFINED_TEMPLATES.find(t => t.id === templateId);
- if (template) {
- for (const status of template.statuses) {
- if (status.color) {
- this.settings.statusColors[status.name] = status.color;
- }
- }
- }
- }
- }
+ // Update status pane if open
+ if (this.statusPaneLeaf && this.statusPaneLeaf.view instanceof StatusPaneView) {
+ (this.statusPaneLeaf.view as StatusPaneView).updateSettings(this.settings);
+ }
+ }
- await this.saveSettings();
- new Notice('Default colors have been restored');
- } catch (error) {
- console.error('Error resetting default colors:', error);
- new Notice('Error resetting default colors. Check console for details.');
- }
- }
+ /**
+ * Force refresh all UI components
+ */
+ public forceRefreshUI(): void {
+ try {
+ // Cancel any pending updates
+ this.debouncedCheckNoteStatus.cancel();
+ this.debouncedUpdateExplorer.cancel();
+ this.debouncedUpdateStatusPane.cancel();
+
+ // Immediate updates
+ this.checkNoteStatus();
+ this.explorerIntegration.updateAllFileExplorerIcons();
+ this.updateStatusPane();
+
+ new Notice('UI forcefully refreshed');
+ } catch (error) {
+ console.error('Error force refreshing UI:', error);
+ new Notice('Error refreshing UI. Check console for details.');
+ }
+ }
- /**
- * Load settings with error handling
- */
- async loadSettings() {
- try {
- const loadedData = await this.loadData();
- this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedData);
- this.initializeDefaultColors();
- } catch (error) {
- console.error('Error loading settings:', error);
- new Notice('Error loading settings. Using defaults. Check console for details.');
- this.settings = Object.assign({}, DEFAULT_SETTINGS);
- this.initializeDefaultColors();
- }
- }
+ /**
+ * Clean up when the plugin is unloaded
+ */
+ onunload(): void {
+ console.log('Unloading Note Status plugin');
+
+ // Cancel debounced functions
+ this.debouncedCheckNoteStatus.cancel();
+ this.debouncedUpdateExplorer.cancel();
+ this.debouncedUpdateStatusPane.cancel();
+
+ // Clean up UI elements
+ this.statusBar.unload?.();
+ this.statusDropdown.unload?.();
+ this.explorerIntegration.unload?.();
+ this.styleService.unload?.();
- /**
- * Initialize default colors
- */
- private initializeDefaultColors() {
- // Ensure default colors are set for all statuses
- for (const [status, color] of Object.entries(DEFAULT_COLORS)) {
- if (!this.settings.statusColors[status]) {
- this.settings.statusColors[status] = color;
- }
- }
+ // Close status pane if open
+ const statusPane = this.app.workspace.getLeavesOfType('status-pane')[0];
+ if (statusPane) statusPane.detach();
- // Also initialize colors from enabled templates if not present
- if (!this.settings.useCustomStatusesOnly) {
- for (const templateId of this.settings.enabledTemplates) {
- const template = PREDEFINED_TEMPLATES.find(t => t.id === templateId);
- if (template) {
- for (const status of template.statuses) {
- if (status.color && !this.settings.statusColors[status.name]) {
- this.settings.statusColors[status.name] = status.color;
- }
- }
- }
- }
- }
- }
-
- /**
- * Save settings with error handling
- */
- async saveSettings() {
- try {
- await this.saveData(this.settings);
-
- // Update services with new settings
- this.statusService.updateSettings(this.settings);
- this.styleService.updateSettings(this.settings);
-
- // Update UI components with new settings
- this.statusBar.updateSettings(this.settings);
- this.statusDropdown.updateSettings(this.settings);
- this.explorerIntegration.updateSettings(this.settings);
- this.statusContextMenu.updateSettings(this.settings);
-
- // Update status pane if open
- if (this.statusPaneLeaf && this.statusPaneLeaf.view instanceof StatusPaneView) {
- (this.statusPaneLeaf.view as StatusPaneView).updateSettings(this.settings);
- }
- } catch (error) {
- console.error('Error saving settings:', error);
- new Notice('Error saving settings. Check console for details.');
- }
- }
-
- public forceRefreshUI(): void {
- try {
- // Cancel any pending updates
- this.debouncedCheckNoteStatus.cancel();
- this.debouncedUpdateExplorer.cancel();
- this.debouncedUpdateStatusPane.cancel();
-
- // Immediate updates
- this.checkNoteStatus();
- this.explorerIntegration.updateAllFileExplorerIcons();
- this.updateStatusPane();
-
- new Notice('UI forcefully refreshed');
- } catch (error) {
- console.error('Error force refreshing UI:', error);
- new Notice('Error refreshing UI. Check console for details.');
- }
- }
-
- /**
- * Clean up when the plugin is unloaded
- */
- onunload() {
- console.log('Unloading Note Status plugin');
-
- // Cancel debounced functions
- this.debouncedCheckNoteStatus.cancel();
- this.debouncedUpdateExplorer.cancel();
- this.debouncedUpdateStatusPane.cancel();
-
- // Clean up UI elements
- this.statusBar.unload?.();
- this.statusDropdown.unload?.();
- this.explorerIntegration.unload?.();
- this.styleService.unload?.();
-
- // Close status pane if open
- const statusPane = this.app.workspace.getLeavesOfType('status-pane')[0];
- if (statusPane) statusPane.detach();
-
- // Remove event listeners for custom events
- window.removeEventListener('note-status:settings-changed', this.saveSettings);
- window.removeEventListener('note-status:status-changed', this.checkNoteStatus);
- window.removeEventListener('note-status:refresh-dropdown', this.statusDropdown.render);
- window.removeEventListener('note-status:refresh-ui', this.checkNoteStatus);
- window.removeEventListener('note-status:open-batch-modal', this.showBatchStatusModal);
-
- console.log('Note Status plugin unloaded');
- }
+ // Remove event listeners for custom events
+ this.removeCustomEventListeners();
+
+ console.log('Note Status plugin unloaded');
+ }
+
+ /**
+ * Remove custom event listeners
+ */
+ private removeCustomEventListeners(): void {
+ window.removeEventListener('note-status:settings-changed', this.saveSettings);
+ window.removeEventListener('note-status:status-changed', this.checkNoteStatus);
+ window.removeEventListener('note-status:refresh-dropdown', this.statusDropdown.render);
+ window.removeEventListener('note-status:refresh-ui', this.checkNoteStatus);
+ window.removeEventListener('note-status:open-batch-modal', this.showBatchStatusModal);
+ }
}
\ No newline at end of file
diff --git a/services/status-service.ts b/services/status-service.ts
index aa55b6b..e6027ce 100644
--- a/services/status-service.ts
+++ b/services/status-service.ts
@@ -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 {
- 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 {
+ 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 {
- // 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 {
- 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 {
+ // 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 {
- 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 {
+ 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 {
- 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 {
+ 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 {
- 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 {
+ 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 {
+ 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 {
- // 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 {
+ // 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 {
- const statusGroups: Record = {};
+ 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 {
+ const statusGroups: Record = {};
- // 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;
+ }
}
\ No newline at end of file
diff --git a/settings/status-pane-view.ts b/settings/status-pane-view.ts
deleted file mode 100644
index bb1b946..0000000
--- a/settings/status-pane-view.ts
+++ /dev/null
@@ -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 {
- await this.setupPane();
- await this.renderGroups('');
- }
-
- async setupPane(): Promise {
- 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 {
- 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 {
- 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() || '');
- }
-}
diff --git a/ui/status-bar.ts b/ui/components/status-bar.ts
similarity index 97%
rename from ui/status-bar.ts
rename to ui/components/status-bar.ts
index f688b00..f76a708 100644
--- a/ui/status-bar.ts
+++ b/ui/components/status-bar.ts
@@ -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
diff --git a/ui/components/status-dropdown-component.ts b/ui/components/status-dropdown-component.ts
new file mode 100644
index 0000000..e506b9d
--- /dev/null
+++ b/ui/components/status-dropdown-component.ts
@@ -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 {
+ 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 {
+ 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();
+ }
+}
\ No newline at end of file
diff --git a/ui/components/status-dropdown.ts b/ui/components/status-dropdown.ts
new file mode 100644
index 0000000..9169e29
--- /dev/null
+++ b/ui/components/status-dropdown.ts
@@ -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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ui/status-pane-view.ts b/ui/components/status-pane-view.ts
similarity index 97%
rename from ui/status-pane-view.ts
rename to ui/components/status-pane-view.ts
index 69b770a..2db23f3 100644
--- a/ui/status-pane-view.ts
+++ b/ui/components/status-pane-view.ts
@@ -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);
})
);
diff --git a/ui/context-menus.ts b/ui/context-menus.ts
deleted file mode 100644
index 473f997..0000000
--- a/ui/context-menus.ts
+++ /dev/null
@@ -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();
- }
-}
\ No newline at end of file
diff --git a/ui/explorer.ts b/ui/explorer.ts
deleted file mode 100644
index 44a2789..0000000
--- a/ui/explorer.ts
+++ /dev/null
@@ -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 = 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 {
- 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();
- }
-}
\ No newline at end of file
diff --git a/ui/integrations/explorer-integration.ts b/ui/integrations/explorer-integration.ts
new file mode 100644
index 0000000..d55a2ee
--- /dev/null
+++ b/ui/integrations/explorer-integration.ts
@@ -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 = 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 {
+ 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();
+ }
+}
\ No newline at end of file
diff --git a/ui/menus/status-context-menu.ts b/ui/menus/status-context-menu.ts
new file mode 100644
index 0000000..db22504
--- /dev/null
+++ b/ui/menus/status-context-menu.ts
@@ -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 {
+ 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();
+ }
+}
\ No newline at end of file
diff --git a/ui/modals.ts b/ui/modals.ts
deleted file mode 100644
index aa63d51..0000000
--- a/ui/modals.ts
+++ /dev/null
@@ -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();
- }
-}
\ No newline at end of file
diff --git a/ui/modals/batch-status-modal.ts b/ui/modals/batch-status-modal.ts
new file mode 100644
index 0000000..d5dfd9b
--- /dev/null
+++ b/ui/modals/batch-status-modal.ts
@@ -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 {
+ 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();
+ }
+}
\ No newline at end of file
diff --git a/ui/status-dropdown-component.ts b/ui/status-dropdown-component.ts
deleted file mode 100644
index 4821959..0000000
--- a/ui/status-dropdown-component.ts
+++ /dev/null
@@ -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();
- }
-}
\ No newline at end of file
diff --git a/ui/status-dropdown.ts b/ui/status-dropdown.ts
deleted file mode 100644
index 9e6912a..0000000
--- a/ui/status-dropdown.ts
+++ /dev/null
@@ -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;
- }
- }
-}
\ No newline at end of file