Merge pull request #25 from devonthesofa/refactor/clean-up-css-and-js

Major Refactor: Modular Codebase & Stability Fixes
This commit is contained in:
Aleix Soler 2025-05-25 09:51:54 +02:00 committed by GitHub
commit 2c811673c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 4761 additions and 5141 deletions

1
.gitignore vendored
View file

@ -20,3 +20,4 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
styles.css

View file

@ -223,13 +223,35 @@ note-status/
│ │ └── file-utils.ts # File-related helpers
│ └── settings/ # Settings UI
│ └── settings-tab.ts # Settings tab definition
└── styles.css # CSS styles
├── styles/ # Modular CSS files
│ ├── base.css # Variables and base styles
│ ├── utils.css # Utility classes
│ ├── components/ # Component-specific styles
│ │ ├── status-bar.css # Status bar styles
│ │ ├── status-pane.css # Status pane styles
│ │ ├── dropdown.css # Dropdown component styles
│ │ ├── explorer.css # File explorer styles
│ │ └── settings.css # Settings UI styles
│ └── index.css # Main CSS import file
└── styles.css # Auto-generated final CSS
```
### Prerequisites
- Node.js and npm installed.
- Obsidian API knowledge (TypeScript-based).
### CSS Modularization
The plugin's CSS has been modularized to improve maintainability:
- **Component-based**: Each UI component has its own CSS file
- **Auto-bundling**: The build process automatically bundles all CSS files into `styles.css`
- **Development workflow**: CSS changes are hot-reloaded during development
When making CSS changes:
1. Modify the appropriate file in the `styles/` directory
2. For new components, create a dedicated CSS file and import it in `styles/index.css`
3. The build process will automatically bundle everything into `styles.css`
### Building the Plugin
1. Clone this repository:
```bash

View file

@ -0,0 +1,4 @@
import { StatusBarController } from './status-bar-controller';
export { StatusBarController as StatusBar};
export default StatusBarController;

View file

@ -0,0 +1,94 @@
import { NoteStatusSettings } from '../../models/types';
import { StatusService } from '../../services/status-service';
import { StatusBarView } from './status-bar-view';
/**
* Controller for the status bar
*/
export class StatusBarController {
private view: StatusBarView;
private settings: NoteStatusSettings;
private statusService: StatusService;
private currentStatuses: string[] = ['unknown'];
constructor(statusBarContainer: HTMLElement, settings: NoteStatusSettings, statusService: StatusService) {
this.view = new StatusBarView(statusBarContainer);
this.settings = settings;
this.statusService = statusService;
this.update(['unknown']);
}
/**
* Update the status bar with new statuses
*/
public update(statuses: string[]): void {
this.currentStatuses = statuses;
this.render();
}
/**
* Render the status bar
*/
private render(): void {
this.view.reset();
if (!this.settings.showStatusBar) {
this.view.hide();
return;
}
if (!this.settings.useMultipleStatuses) {
this.renderStatuses([this.currentStatuses[0]]);
} else {
this.renderStatuses(this.currentStatuses);
}
this.handleAutoHide();
}
/**
* Render statuses - handles both single and multiple status cases
*/
private renderStatuses(statuses: string[]): void {
const statusDetails = statuses.map(status => {
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
return {
name: status,
icon: this.statusService.getStatusIcon(status),
tooltipText: statusObj?.description ? `${status} - ${statusObj.description}` : status
};
});
this.view.renderStatuses(statusDetails);
}
/**
* Handle auto-hide behavior
*/
private handleAutoHide(): void {
const onlyUnknown = this.currentStatuses.length === 1 &&
this.currentStatuses[0] === 'unknown';
if (this.settings.autoHideStatusBar && onlyUnknown) {
this.view.hide();
} else {
this.view.show();
}
}
/**
* Update settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.render();
}
/**
* Clean up when plugin is unloaded
*/
public unload(): void {
this.view.destroy();
}
}

View file

@ -0,0 +1,107 @@
import { setTooltip } from 'obsidian';
/**
* Renders the status bar UI
*/
export class StatusBarView {
private element: HTMLElement;
constructor(element: HTMLElement) {
this.element = element;
this.element.addClass('note-status-bar');
}
/**
* Clears the element and resets CSS classes
*/
reset(): void {
this.element.empty();
this.element.removeClass('left', 'hidden', 'auto-hide', 'visible');
this.element.addClass('note-status-bar');
}
/**
* Hide the status bar
*/
hide(): void {
this.element.addClass('hidden');
this.element.removeClass('visible');
}
/**
* Show the status bar
*/
show(): void {
this.element.removeClass('hidden');
this.element.addClass('visible');
}
renderStatuses(statuses: Array<{name: string, icon: string, tooltipText: string}>): void {
if (statuses.length === 1) {
this.renderSingleStatus(statuses[0].name, statuses[0].icon, statuses[0].tooltipText);
} else {
this.renderMultipleStatuses(statuses);
}
}
/**
* Render a single status
*/
private renderSingleStatus(status: string, icon: string, tooltipText: string): void {
const statusText = this.element.createEl('span', {
text: `Status: ${status}`,
cls: `note-status-${status}`
});
setTooltip(statusText, tooltipText);
const statusIcon = this.element.createEl('span', {
text: icon,
cls: `note-status-icon status-${status}`
});
setTooltip(statusIcon, tooltipText);
}
/**
* Render multiple statuses
*/
private renderMultipleStatuses(statuses: Array<{name: string, icon: string, tooltipText: string}>): void {
this.element.createEl('span', {
text: 'Statuses: ',
cls: 'note-status-label'
});
const badgesContainer = this.element.createEl('span', {
cls: 'note-status-badges'
});
statuses.forEach(status => this.createStatusBadge(badgesContainer, status));
}
/**
* Create a status badge for multiple status display
*/
private createStatusBadge(container: HTMLElement, status: {name: string, icon: string, tooltipText: string}): void {
const badge = container.createEl('span', {
cls: `note-status-badge status-${status.name}`
});
setTooltip(badge, status.tooltipText);
badge.createEl('span', {
text: status.icon,
cls: 'note-status-badge-icon'
});
badge.createEl('span', {
text: status.name,
cls: 'note-status-badge-text'
});
}
/**
* Clean up the element
*/
destroy(): void {
this.element.empty();
}
}

View file

@ -0,0 +1,29 @@
/**
* Event handlers setup and cleanup for the dropdown
*/
/**
* Setup event handlers for the dropdown
*/
export function setupDropdownEvents(options: {
clickOutsideHandler: (e: MouseEvent) => void,
escapeKeyHandler: (e: KeyboardEvent) => void
}): void {
const { clickOutsideHandler, escapeKeyHandler } = options;
document.addEventListener('click', clickOutsideHandler);
document.addEventListener('keydown', escapeKeyHandler);
}
/**
* Remove dropdown event handlers
*/
export function removeDropdownEvents(options: {
clickOutsideHandler: (e: MouseEvent) => void,
escapeKeyHandler: (e: KeyboardEvent) => void
}): void {
const { clickOutsideHandler, escapeKeyHandler } = options;
document.removeEventListener('click', clickOutsideHandler);
document.removeEventListener('keydown', escapeKeyHandler);
}

View file

@ -0,0 +1,255 @@
import { MarkdownView, Editor, Notice, TFile, App } from 'obsidian';
import { DropdownUI } from './dropdown-ui';
import { DropdownOptions, DropdownDependencies } from './types';
import { createDummyTarget } from './dropdown-position';
import { StatusService } from 'services/status-service';
import { NoteStatusSettings } from 'models/types';
/**
* High-level manager for status dropdown interactions
*/
export class DropdownManager {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private currentStatuses: string[] = ['unknown'];
private toolbarButton?: HTMLElement;
private dropdownUI: DropdownUI;
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
const deps: DropdownDependencies = { app, settings, statusService };
this.dropdownUI = new DropdownUI(deps);
this.setupDropdownCallbacks();
}
/**
* Set up dropdown callbacks
*/
private setupDropdownCallbacks(): void {
this.dropdownUI.setOnRemoveStatusHandler(async (status, targetFile) => {
if (!targetFile) return;
const isMultiple = Array.isArray(targetFile);
await this.statusService.handleStatusChange({
files: targetFile,
statuses: status,
operation: 'remove',
showNotice: isMultiple
});
});
this.dropdownUI.setOnSelectStatusHandler(async (status, targetFile) => {
// Check if handling multiple files
const isMultipleFiles = Array.isArray(targetFile) && targetFile.length > 1;
if (isMultipleFiles) {
const files = targetFile as TFile[];
// Count how many files already have this status
const filesWithStatus = files.filter(file =>
this.statusService.getFileStatuses(file).includes(status)
);
// If ALL have the status, remove it. Otherwise, add it
const operation = filesWithStatus.length === files.length ? 'remove' : (!this.settings.useMultipleStatuses) ? 'set':'add';
await this.statusService.handleStatusChange({
files: targetFile,
statuses: status,
isMultipleSelection: true,
operation: operation
});
} else {
// For individual files, maintain default behavior
await this.statusService.handleStatusChange({
files: targetFile,
statuses: status
});
}
});
}
/**
* Updates the dropdown UI based on current statuses
*/
public update(currentStatuses: string[] | string, _file?: TFile): void {
this.currentStatuses = Array.isArray(currentStatuses) ?
[...currentStatuses] : [currentStatuses];
this.dropdownUI.updateStatuses(this.currentStatuses);
}
/**
* Updates settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.dropdownUI.updateSettings(settings);
}
/**
* Get position from cursor or fallback
*/
private getCursorPosition(editor: Editor, view: MarkdownView): { x: number, y: number } {
try {
const cursor = editor.getCursor('head');
const lineElement = view.contentEl.querySelector(`.cm-line:nth-child(${cursor.line + 1})`);
if (lineElement) {
const rect = lineElement.getBoundingClientRect();
return { x: rect.left + 20, y: rect.bottom + 5 };
}
const editorEl = view.contentEl.querySelector('.cm-editor');
if (editorEl) {
const rect = editorEl.getBoundingClientRect();
return { x: rect.left + 100, y: rect.top + 100 };
}
} catch (error) {
console.error('Error getting position for dropdown:', error);
}
return { x: window.innerWidth / 2, y: window.innerHeight / 3 };
}
/**
* Universal function to open the status dropdown
*/
public openStatusDropdown(options: DropdownOptions): void {
const activeFile = this.app.workspace.getActiveFile();
const files = options.files || (activeFile ? [activeFile] : []);
if (!files.length) {
new Notice('No files selected');
return;
}
if (!files.length || !files[0]) {
new Notice('No files selected');
return;
}
if (this.dropdownUI.isOpen) {
this.resetDropdown();
return
}
const isSingleFile = files.length === 1;
// Set up target files appropriately
if (isSingleFile) {
const targetFile = files[0];
this.dropdownUI.setTargetFile(targetFile);
const currentStatuses = this.statusService.getFileStatuses(targetFile);
this.dropdownUI.updateStatuses(currentStatuses);
} else {
// For multiple files, set the whole collection
this.dropdownUI.setTargetFiles(files);
const commonStatuses = this.findCommonStatuses(files);
this.dropdownUI.updateStatuses(commonStatuses);
}
this.positionAndOpenDropdown(options);
}
/**
* Reset dropdown state before opening
*/
public resetDropdown(): void {
this.dropdownUI.close();
this.dropdownUI.setTargetFile(null);
}
/**
* Position and open the dropdown
*/
private positionAndOpenDropdown(options: {
target?: HTMLElement;
position?: { x: number, y: number };
editor?: Editor;
view?: MarkdownView;
}): void {
if (options.editor && options.view) {
const position = this.getCursorPosition(options.editor, options.view);
this.openWithPosition(position);
return;
}
if (options.target) {
if (options.position) {
this.dropdownUI.open(options.target, options.position);
} else {
const rect = options.target.getBoundingClientRect();
this.dropdownUI.open(options.target, {
x: rect.left,
y: rect.bottom + 5
});
}
return;
}
if (options.position) {
this.openWithPosition(options.position);
return;
}
this.openWithPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 3
});
}
/**
* Open dropdown at a specific position using dummy target
*/
private openWithPosition(position: { x: number, y: number }): void {
const dummyTarget = createDummyTarget(position);
this.dropdownUI.open(dummyTarget, position);
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
}, 100);
}
/**
* Find common statuses across multiple files
*/
private findCommonStatuses(files: TFile[]): string[] {
if (files.length === 0) return ['unknown'];
const firstFileStatuses = this.statusService.getFileStatuses(files[0]);
return firstFileStatuses.filter(status =>
status !== 'unknown' &&
files.every(file => this.statusService.getFileStatuses(file).includes(status))
);
}
/**
* Render method (kept for compatibility)
*/
public render(): void {
// No-op - dropdown component handles rendering internally
}
/**
* Remove dropdown when plugin is unloaded
*/
public unload(): void {
this.dropdownUI.dispose();
if (this.toolbarButton) {
this.toolbarButton.remove();
this.toolbarButton = undefined;
}
}
}

View file

@ -0,0 +1,105 @@
/**
* Dropdown positioning utilities
*/
/**
* Position the dropdown
*/
export function positionDropdown(options: {
dropdownElement: HTMLElement,
targetEl: HTMLElement,
position?: { x: number, y: number }
}): void {
const { dropdownElement, targetEl, position } = options;
if (position) {
positionAt(dropdownElement, position.x, position.y);
} else {
positionRelativeTo(dropdownElement, targetEl);
}
}
/**
* Position the dropdown at specific coordinates
*/
function positionAt(dropdownElement: HTMLElement, x: number, y: number): void {
dropdownElement.addClass('note-status-popover-fixed');
dropdownElement.style.setProperty('--pos-x-px', `${x}px`);
dropdownElement.style.setProperty('--pos-y-px', `${y}px`);
setTimeout(() => adjustPositionToViewport(dropdownElement), 0);
}
/**
* Adjust the dropdown position to ensure it's visible in the viewport
*/
function adjustPositionToViewport(dropdownElement: HTMLElement): void {
const rect = dropdownElement.getBoundingClientRect();
if (rect.right > window.innerWidth) {
dropdownElement.addClass('note-status-popover-right');
dropdownElement.style.setProperty('--right-offset-px', '10px');
} else {
dropdownElement.removeClass('note-status-popover-right');
}
if (rect.bottom > window.innerHeight) {
dropdownElement.addClass('note-status-popover-bottom');
dropdownElement.style.setProperty('--bottom-offset-px', '10px');
} else {
dropdownElement.removeClass('note-status-popover-bottom');
}
const maxHeight = window.innerHeight - rect.top - 20;
dropdownElement.style.setProperty('--max-height-px', `${maxHeight}px`);
}
/**
* Position the dropdown relative to a target element
*/
function positionRelativeTo(dropdownElement: HTMLElement, targetEl: HTMLElement): void {
dropdownElement.addClass('note-status-popover-fixed');
const targetRect = targetEl.getBoundingClientRect();
dropdownElement.style.setProperty('--pos-y-px', `${targetRect.bottom + 5}px`);
dropdownElement.style.setProperty('--pos-x-px', `${targetRect.left}px`);
setTimeout(() => adjustRelativePosition(dropdownElement, targetRect), 0);
}
/**
* Adjust position when positioned relative to an element
*/
function adjustRelativePosition(dropdownElement: HTMLElement, targetRect: DOMRect): void {
const rect = dropdownElement.getBoundingClientRect();
if (rect.right > window.innerWidth) {
dropdownElement.addClass('note-status-popover-right');
dropdownElement.style.setProperty('--right-offset-px', `${window.innerWidth - targetRect.right}px`);
} else {
dropdownElement.removeClass('note-status-popover-right');
}
if (rect.bottom > window.innerHeight) {
dropdownElement.addClass('note-status-popover-bottom');
dropdownElement.style.setProperty('--bottom-offset-px', `${window.innerHeight - targetRect.top + 5}px`);
} else {
dropdownElement.removeClass('note-status-popover-bottom');
}
const maxHeight = window.innerHeight - rect.top - 20;
dropdownElement.style.setProperty('--max-height-px', `${maxHeight}px`);
}
/**
* Create a dummy target element for positioning
*/
export function createDummyTarget(position: { x: number, y: number }): HTMLElement {
const dummyTarget = document.createElement('div');
dummyTarget.addClass('note-status-dummy-target');
dummyTarget.style.setProperty('--pos-x-px', `${position.x}px`);
dummyTarget.style.setProperty('--pos-y-px', `${position.y}px`);
document.body.appendChild(dummyTarget);
return dummyTarget;
}

View file

@ -0,0 +1,314 @@
import { setIcon, TFile, setTooltip } from 'obsidian';
import { StatusRemoveHandler, StatusSelectHandler } from './types';
import { NoteStatusSettings, Status } from 'models/types';
import { StatusService } from 'services/status-service';
/**
* Render the dropdown content
*/
export function renderDropdownContent(options: {
dropdownElement: HTMLElement,
settings: NoteStatusSettings,
statusService: StatusService,
currentStatuses: string[],
targetFile: TFile | null,
targetFiles: TFile[],
onRemoveStatus: StatusRemoveHandler,
onSelectStatus: StatusSelectHandler
}): void {
const {
dropdownElement,
settings,
statusService,
currentStatuses,
targetFile,
targetFiles,
onRemoveStatus,
onSelectStatus
} = options;
dropdownElement.empty();
// Create UI sections
createHeader(dropdownElement, targetFiles);
const target = targetFiles.length > 1 ? targetFiles : targetFile;
createStatusChips(dropdownElement, currentStatuses, statusService, target ?? [], onRemoveStatus);
const searchInput = createSearchFilter(dropdownElement);
// Create status options container
const statusOptionsContainer = dropdownElement.createDiv({
cls: 'note-status-options-container'
});
// Get all available statuses (excluding 'unknown')
const allStatuses = statusService.getAllStatuses()
.filter(status => status.name !== 'unknown');
// Function to populate options with filtering
const populateOptions = (filter = '') => {
populateStatusOptions({
container: statusOptionsContainer,
statuses: allStatuses,
currentStatuses,
settings,
targetFiles,
onSelectStatus,
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
*/
function createHeader(dropdownElement: HTMLElement, targetFiles: TFile[]): void {
const headerEl = dropdownElement.createDiv({ cls: 'note-status-popover-header' });
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' });
// If multiple files are selected, show count
if (targetFiles.length > 1) {
titleEl.createSpan({
text: ` (${targetFiles.length} files)`,
cls: 'note-status-popover-count'
});
}
}
/**
* Create the status chips section
*/
function createStatusChips(
dropdownElement: HTMLElement,
currentStatuses: string[],
statusService: StatusService,
targetFile: TFile | TFile[],
onRemoveStatus: StatusRemoveHandler
): void {
const chipsContainer = dropdownElement.createDiv({ cls: 'note-status-popover-chips' });
const hasNoValidStatus = currentStatuses.length === 0 ||
(currentStatuses.length === 1 && currentStatuses[0] === 'unknown');
if (hasNoValidStatus) {
chipsContainer.createDiv({
cls: 'note-status-empty-indicator',
text: 'No status assigned'
});
} else {
createStatusChipElements(chipsContainer, currentStatuses, statusService, targetFile, onRemoveStatus);
}
}
/**
* Create chips for all current statuses
*/
function createStatusChipElements(
container: HTMLElement,
currentStatuses: string[],
statusService: StatusService,
target: TFile | TFile[],
onRemoveStatus: StatusRemoveHandler
): void {
currentStatuses.forEach(status => {
if (status === 'unknown') return;
const statusObj = statusService.getAllStatuses().find(s => s.name === status);
if (!statusObj) return;
createSingleStatusChip(container, status, statusObj, target, onRemoveStatus);
});
}
/**
* Create a single status chip
*/
function createSingleStatusChip(
container: HTMLElement,
status: string,
statusObj: Status,
target: TFile | TFile[],
onRemoveStatus: StatusRemoveHandler
): void {
const chipEl = container.createDiv({
cls: `note-status-chip status-${status}`
});
// Status icon and name
chipEl.createSpan({
text: statusObj.icon,
cls: 'note-status-chip-icon'
});
chipEl.createSpan({
text: statusObj.name,
cls: 'note-status-chip-text'
});
addRemoveButton(chipEl, status, statusObj, target, onRemoveStatus);
}
/**
* Add a remove button to a status chip
*/
function addRemoveButton(
chipEl: HTMLElement,
status: string,
statusObj: Status,
targetFile: TFile | TFile[] | null,
onRemoveStatus: StatusRemoveHandler
): void {
const tooltipValue = statusObj.description ? `${status} - ${statusObj.description}`: status;
setTooltip(chipEl, tooltipValue);
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();
chipEl.addClass('note-status-chip-removing');
setTimeout(async () => {
if (targetFile) {
await onRemoveStatus(status, targetFile);
}
}, 150);
});
}
/**
* Create the search filter input
*/
function createSearchFilter(dropdownElement: HTMLElement): HTMLInputElement {
const searchContainer = dropdownElement.createDiv({ cls: 'note-status-popover-search' });
return searchContainer.createEl('input', {
type: 'text',
placeholder: 'Filter statuses...',
cls: 'note-status-popover-search-input'
});
}
/**
* Populate status options with optional filtering
*/
function populateStatusOptions(options: {
container: HTMLElement,
statuses: Status[],
currentStatuses: string[],
settings: NoteStatusSettings,
targetFiles: TFile[],
onSelectStatus: StatusSelectHandler,
filter?: string
}): void {
const {
container,
statuses,
currentStatuses,
settings,
targetFiles,
onSelectStatus,
filter = ''
} = options;
container.empty();
const filteredStatuses = filter ?
statuses.filter(status =>
status.name.toLowerCase().includes(filter.toLowerCase()) ||
status.icon.includes(filter)
) :
statuses;
if (filteredStatuses.length === 0) {
container.createDiv({
cls: 'note-status-empty-options',
text: filter ? `No statuses match "${filter}"` : 'No statuses found'
});
return;
}
filteredStatuses.forEach(status => {
createStatusOption({
container,
status,
isSelected: currentStatuses.includes(status.name),
settings,
targetFiles,
onSelectStatus
});
});
}
/**
* Create a single status option element
*/
function createStatusOption(options: {
container: HTMLElement,
status: Status,
isSelected: boolean,
settings: NoteStatusSettings,
targetFiles: TFile[],
onSelectStatus: StatusSelectHandler
}): void {
const { container, status, isSelected, settings, targetFiles, onSelectStatus } = options;
const optionEl = container.createDiv({
cls: `note-status-option ${isSelected ? 'is-selected' : ''} status-${status.name}`
});
// Status icon and name
optionEl.createSpan({
text: status.icon,
cls: 'note-status-option-icon'
});
optionEl.createSpan({
text: status.name,
cls: 'note-status-option-text'
});
// Add tooltip if description available
if (status.description) {
setTooltip(optionEl, `${status.name} - ${status.description}`);
}
// Check icon for selected status
if (isSelected) {
const checkIcon = optionEl.createDiv({ cls: 'note-status-option-check' });
setIcon(checkIcon, 'check');
}
optionEl.addEventListener('click', () => {
optionEl.addClass('note-status-option-selecting');
setTimeout(async () => {
if (targetFiles.length > 0) {
await onSelectStatus(status.name, targetFiles);
}
}, 150);
});
}

View file

@ -0,0 +1,220 @@
import { App, TFile } from 'obsidian';
import { DropdownDependencies, StatusRemoveHandler, StatusSelectHandler } from './types';
import { positionDropdown } from './dropdown-position';
import { renderDropdownContent } from './dropdown-render';
import { setupDropdownEvents } from './dropdown-events';
import { StatusService } from 'services/status-service';
import { NoteStatusSettings } from 'models/types';
/**
* Core UI component for the status dropdown
*/
export class DropdownUI {
private app: App;
private statusService: StatusService;
private settings: NoteStatusSettings;
private dropdownElement: HTMLElement | null = null;
private currentStatuses: string[] = ['unknown'];
private targetFile: TFile | null = null;
private targetFiles: TFile[] = [];
private animationDuration = 220;
public isOpen = false;
private onRemoveStatus: StatusRemoveHandler = async () => {};
private onSelectStatus: StatusSelectHandler = async () => {};
// Event handlers
private clickOutsideHandler: (e: MouseEvent) => void;
private escapeKeyHandler: (e: KeyboardEvent) => void;
constructor({ app, settings, statusService }: DropdownDependencies) {
this.app = app;
this.statusService = statusService;
this.settings = settings;
// Bind methods
this.clickOutsideHandler = this.handleClickOutside.bind(this);
this.escapeKeyHandler = this.handleEscapeKey.bind(this);
}
/**
* Set the target file for status updates
*/
public setTargetFile(file: TFile | null): void {
this.targetFile = file;
this.targetFiles = file ? [file] : [];
}
/**
* Set multiple target files for status updates
*/
public setTargetFiles(files: TFile[]): void {
this.targetFiles = [...files];
this.targetFile = files.length === 1 ? files[0] : null;
}
/**
* Register handler for removing a status
*/
public setOnRemoveStatusHandler(handler: StatusRemoveHandler): void {
this.onRemoveStatus = handler;
}
/**
* Register handler for selecting a status
*/
public setOnSelectStatusHandler(handler: StatusSelectHandler): void {
this.onSelectStatus = handler;
}
/**
* Updates the current statuses
*/
public updateStatuses(statuses: string[] | string): void {
this.currentStatuses = Array.isArray(statuses) ? [...statuses] : [statuses];
if (this.isOpen && this.dropdownElement) {
this.refreshDropdownContent();
}
}
/**
* Updates settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
if (this.isOpen && this.dropdownElement) {
this.refreshDropdownContent();
}
}
/**
* Toggle the dropdown visibility
*/
public toggle(targetEl: HTMLElement, position?: { x: number, y: number }): void {
if (this.isOpen) {
this.close();
setTimeout(() => {
if (!this.isOpen && !this.dropdownElement) {
this.open(targetEl, position);
}
}, 50);
} else {
this.open(targetEl, position);
}
}
/**
* Open the dropdown
*/
public open(targetEl: HTMLElement, position?: { x: number, y: number }): void {
if (this.isOpen || this.dropdownElement) {
this.close();
setTimeout(() => this.actuallyOpen(targetEl, position), 10);
return;
}
this.actuallyOpen(targetEl, position);
}
/**
* Actually open the dropdown (internal method)
*/
private actuallyOpen(targetEl: HTMLElement, position?: { x: number, y: number }): void {
this.isOpen = true;
// Create dropdown element
this.createDropdownElement();
this.refreshDropdownContent();
// Position the dropdown
positionDropdown({
dropdownElement: this.dropdownElement!,
targetEl,
position
});
this.dropdownElement?.addClass('note-status-popover-animate-in');
// Add event listeners with slight delay to prevent immediate triggering
setTimeout(() => {
setupDropdownEvents({
clickOutsideHandler: this.clickOutsideHandler,
escapeKeyHandler: this.escapeKeyHandler
});
}, 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;
this.dropdownElement.addClass('note-status-popover-animate-out');
document.removeEventListener('click', this.clickOutsideHandler);
document.removeEventListener('keydown', this.escapeKeyHandler);
this.isOpen = false;
if (this.dropdownElement) {
this.dropdownElement.remove();
this.dropdownElement = null;
}
}
/**
* Refresh the dropdown content
*/
private refreshDropdownContent(): void {
if (!this.dropdownElement) return;
renderDropdownContent({
dropdownElement: this.dropdownElement,
settings: this.settings,
statusService: this.statusService,
currentStatuses: this.currentStatuses,
targetFile: this.targetFile,
targetFiles: this.targetFiles,
onRemoveStatus: this.onRemoveStatus,
onSelectStatus: this.onSelectStatus
});
}
/**
* Handle click outside the dropdown
*/
private handleClickOutside(e: MouseEvent): void {
if (this.dropdownElement && !this.dropdownElement.contains(e.target as Node)) {
this.close();
}
}
/**
* Handle escape key to close dropdown
*/
private handleEscapeKey(e: KeyboardEvent): void {
if (e.key === 'Escape') {
this.close();
}
}
/**
* Dispose of resources
*/
public dispose(): void {
this.close();
}
}

View file

@ -0,0 +1,6 @@
import { DropdownManager } from './dropdown-manager';
import { DropdownOptions } from './types';
export type { DropdownOptions };
export { DropdownManager as StatusDropdown };
export default DropdownManager;

View file

@ -0,0 +1,36 @@
// # Tipos comunes
import { NoteStatusSettings } from 'models/types';
import { App, TFile, Editor, MarkdownView } from 'obsidian';
import { StatusService } from 'services/status-service';
/**
* Options for opening status dropdown
*/
export interface DropdownOptions {
target?: HTMLElement;
position?: { x: number, y: number };
files?: TFile[];
editor?: Editor;
view?: MarkdownView;
mode?: 'replace' | 'add' | 'remove' | 'toggle';
onStatusChange?: (statuses: string[]) => void;
}
/**
* Status removal handler function type
*/
export type StatusRemoveHandler = (status: string, targetFile?: TFile | TFile[]) => Promise<void>;
/**
* Status selection handler function type
*/
export type StatusSelectHandler = (status: string, targetFile: TFile | TFile[]) => Promise<void>;
/**
* Common dependencies for dropdown components
*/
export interface DropdownDependencies {
app: App;
settings: NoteStatusSettings;
statusService: StatusService;
}

View file

@ -0,0 +1,75 @@
import { NoteStatusSettings } from "models/types";
import { StatusService } from "services/status-service";
export class ToolbarButton {
private element: HTMLElement | null = null;
private settings: NoteStatusSettings;
private statusService: StatusService;
constructor(settings: NoteStatusSettings, statusService: StatusService) {
this.settings = settings;
this.statusService = statusService;
}
public createElement(): HTMLElement {
const button = document.createElement('button');
button.addClass('note-status-toolbar-button', 'clickable-icon', 'view-action');
button.setAttribute('aria-label', 'Note status');
this.element = button;
return button;
}
public updateDisplay(statuses: string[]): void {
if (!this.element) return;
this.element.empty();
const hasValidStatus = statuses.length > 0 && statuses[0] !== 'unknown';
const badgeContainer = document.createElement('div');
badgeContainer.addClass('note-status-toolbar-badge-container');
if (hasValidStatus) {
this.renderStatusBadge(badgeContainer, statuses);
} else {
this.renderUnknownBadge(badgeContainer);
}
this.element.appendChild(badgeContainer);
}
private renderStatusBadge(container: HTMLElement, statuses: string[]): void {
const primaryStatus = statuses[0];
const icon = this.statusService.getStatusIcon(primaryStatus);
const iconSpan = document.createElement('span');
iconSpan.addClass(`note-status-toolbar-icon`, `status-${primaryStatus}`);
iconSpan.textContent = icon;
container.appendChild(iconSpan);
if (this.settings.useMultipleStatuses && statuses.length > 1) {
const countBadge = document.createElement('span');
countBadge.addClass('note-status-count-badge');
countBadge.textContent = `${statuses.length}`;
container.appendChild(countBadge);
}
}
private renderUnknownBadge(container: HTMLElement): void {
const iconSpan = document.createElement('span');
iconSpan.addClass('note-status-toolbar-icon', 'status-unknown');
iconSpan.textContent = this.statusService.getStatusIcon('unknown');
container.appendChild(iconSpan);
}
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
public destroy(): void {
if (this.element) {
this.element.remove();
this.element = null;
}
}
}

View file

@ -23,6 +23,7 @@ export const DEFAULT_SETTINGS: NoteStatusSettings = {
useCustomStatusesOnly: false,
useMultipleStatuses: true,
tagPrefix: 'obsidian-note-status',
strictStatuses: false, // Default to show all statuses from frontmatter
excludeUnknownStatus: true, // Default to exclude unknown status files for better performance
};
@ -35,4 +36,4 @@ export const DEFAULT_COLORS: Record<string, string> = {
completed: '#00aaff', // Blue for accent
dropped: '#ff0000', // Red for error
unknown: '#888888' // Gray for muted
};
};

View file

@ -11,6 +11,17 @@ if you want to view the source, please visit the github repository of this plugi
const prod = (process.argv[2] === "production");
// CSS bundling function
const buildStyles = async () => {
await esbuild.build({
entryPoints: ["styles/index.css"],
bundle: true,
outfile: "styles.css",
minify: prod,
logLevel: "info",
});
};
const context = await esbuild.context({
banner: {
js: banner,
@ -31,7 +42,8 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
...builtins
],
format: "cjs",
target: "es2018",
logLevel: "info",
@ -41,9 +53,20 @@ const context = await esbuild.context({
minify: prod,
});
// CSS context for watch mode
const cssContext = await esbuild.context({
entryPoints: ["styles/index.css"],
bundle: true,
outfile: "styles.css",
minify: prod,
logLevel: "info",
});
if (prod) {
await context.rebuild();
await buildStyles(); // Build CSS in production mode
process.exit(0);
} else {
await context.watch();
}
await cssContext.watch(); // Watch CSS files in development mode
}

View file

@ -0,0 +1,227 @@
// integrations/commands/command-integration.ts
import { App, Editor, MarkdownView, Notice, TFile } from 'obsidian';
import { NoteStatusSettings } from '../../models/types';
import { StatusService } from 'services/status-service';
import { StatusDropdown } from 'components/status-dropdown';
import { StatusPaneViewController } from 'views/status-pane-view';
import NoteStatus from 'main';
export class CommandIntegration {
private app: App;
private plugin: NoteStatus;
private settings: NoteStatusSettings;
private statusService: StatusService;
private statusDropdown: StatusDropdown;
constructor(
app: App,
plugin: NoteStatus,
settings: NoteStatusSettings,
statusService: StatusService,
statusDropdown: StatusDropdown
) {
this.app = app;
this.plugin = plugin;
this.settings = settings;
this.statusService = statusService;
this.statusDropdown = statusDropdown;
}
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
public registerCommands(): void {
// Open status pane
this.plugin.addCommand({
id: 'open-status-pane',
name: 'Open status pane',
callback: () => StatusPaneViewController.open(this.app)
});
// Change status of current note
this.plugin.addCommand({
id: 'change-status',
name: 'Change status of current note',
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
this.statusDropdown.openStatusDropdown({ files: [file] });
}
return true;
}
});
// Insert status metadata
this.plugin.addCommand({
id: 'insert-status-metadata',
name: 'Insert status metadata',
editorCheckCallback: (checking: boolean, editor: Editor, view: MarkdownView) => {
if (!view.file) return false;
const statuses = this.statusService.getFileStatuses(view.file);
const hasNoStatus = statuses.length === 1 && statuses[0] === 'unknown';
if (!checking && hasNoStatus) {
this.statusService.insertStatusMetadataInEditor(editor);
new Notice('Status metadata inserted');
}
return hasNoStatus;
}
});
// Cycle through statuses
this.plugin.addCommand({
id: 'cycle-status',
name: 'Cycle to next status',
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
this.cycleStatus(file);
}
return true;
}
});
// Quick status commands for common statuses
const quickStatuses = ['active', 'completed', 'onHold', 'dropped'];
quickStatuses.forEach(status => {
this.plugin.addCommand({
id: `set-status-${status}`,
name: `Set status to ${status}`,
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
this.statusService.handleStatusChange({
files: file,
statuses: status,
operation: 'set'
});
new Notice(`Status set to ${status}`);
}
return true;
}
});
});
// Clear status
this.plugin.addCommand({
id: 'clear-status',
name: 'Clear status (set to unknown)',
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
this.statusService.handleStatusChange({
files: file,
statuses: 'unknown',
operation: 'set'
});
new Notice('Status cleared');
}
return true;
}
});
// Copy status from current note
this.plugin.addCommand({
id: 'copy-status',
name: 'Copy status from current note',
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
const statuses = this.statusService.getFileStatuses(file);
(this.app as any).clipboard = statuses;
new Notice(`Copied status: ${statuses.join(', ')}`);
}
return true;
}
});
// Paste status to current note
this.plugin.addCommand({
id: 'paste-status',
name: 'Paste status to current note',
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
const clipboard = (this.app as any).clipboard;
if (!file || !clipboard || !Array.isArray(clipboard)) return false;
if (!checking) {
this.statusService.handleStatusChange({
files: file,
statuses: clipboard,
operation: 'set'
});
new Notice(`Pasted status: ${clipboard.join(', ')}`);
}
return true;
}
});
// Toggle multiple statuses mode
this.plugin.addCommand({
id: 'toggle-multiple-statuses',
name: 'Toggle multiple statuses mode',
callback: () => {
this.settings.useMultipleStatuses = !this.settings.useMultipleStatuses;
this.plugin.saveSettings();
new Notice(`Multiple statuses mode ${this.settings.useMultipleStatuses ? 'enabled' : 'disabled'}`);
}
});
// Search notes by status
this.plugin.addCommand({
id: 'search-by-status',
name: 'Search notes by current status',
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
const statuses = this.statusService.getFileStatuses(file);
const query = `[${this.settings.tagPrefix}:"${statuses[0]}"]`;
(this.app as any).internalPlugins.getPluginById('global-search').instance.openGlobalSearch(query);
}
return true;
}
});
}
private cycleStatus(file: TFile): void {
const allStatuses = this.statusService.getAllStatuses()
.filter(s => s.name !== 'unknown')
.map(s => s.name);
if (allStatuses.length === 0) {
new Notice('No statuses available');
return;
}
const currentStatuses = this.statusService.getFileStatuses(file);
const currentStatus = currentStatuses[0];
let nextIndex = 0;
if (currentStatus !== 'unknown') {
const currentIndex = allStatuses.indexOf(currentStatus);
nextIndex = (currentIndex + 1) % allStatuses.length;
}
this.statusService.handleStatusChange({
files: file,
statuses: allStatuses[nextIndex],
operation: 'set'
});
new Notice(`Status changed to ${allStatuses[nextIndex]}`);
}
}

View file

@ -0,0 +1,95 @@
import { App, Menu, TFile } from 'obsidian';
import { NoteStatusSettings } from 'models/types';
import { StatusService } from 'services/status-service';
import { ExplorerIntegration } from 'integrations/explorer/explorer-integration';
import { StatusContextMenu } from 'integrations/context-menu/status-context-menu';
/**
* Gestiona la integración de menús contextuales con el explorador de archivos
*/
export class FileContextMenuIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private explorerIntegration: ExplorerIntegration;
private statusContextMenu: StatusContextMenu;
private fileMenuEventRef: any;
private filesMenuEventRef: any;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
explorerIntegration: ExplorerIntegration,
statusContextMenu: StatusContextMenu
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.explorerIntegration = explorerIntegration;
this.statusContextMenu = statusContextMenu;
}
/**
* Actualiza la configuración
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
/**
* Registra los eventos del menú contextual de archivos
*/
public registerFileContextMenuEvents(): void {
this.fileMenuEventRef = (menu: Menu, file: any, source: string) => {
if (source === 'file-explorer-context-menu' && file instanceof TFile && file.extension === 'md') {
this.addStatusChangeMenu(menu, file);
}
};
this.filesMenuEventRef = (menu: Menu, files: any[]) => {
const mdFiles = files.filter(file =>
file instanceof TFile && file.extension === 'md'
) as TFile[];
if (mdFiles.length > 0) {
this.addBatchStatusChangeMenu(menu, mdFiles);
}
};
this.app.workspace.on('file-menu', this.fileMenuEventRef);
this.app.workspace.on('files-menu', this.filesMenuEventRef);
}
/**
* Añade opción de cambio de estado al menú contextual de un archivo
*/
private addStatusChangeMenu(menu: Menu, file: TFile): void {
this.statusContextMenu.addStatusMenuItemToSingleFile(menu, file, (file) => {
const selectedFiles = this.explorerIntegration.getSelectedFiles();
if (selectedFiles.length > 1) {
this.statusContextMenu.showForFiles(selectedFiles);
} else {
this.statusContextMenu.showForFiles([file]);
}
});
}
/**
* Añade opción de cambio de estado para múltiples archivos
*/
private addBatchStatusChangeMenu(menu: Menu, files: TFile[]): void {
this.statusContextMenu.addStatusMenuItemToBatch(menu, files, (files) => {
this.statusContextMenu.showForFiles(files);
});
}
public unload(): void {
if (this.fileMenuEventRef) {
this.app.workspace.off('file-menu', this.fileMenuEventRef);
}
if (this.filesMenuEventRef) {
this.app.workspace.off('files-menu', this.filesMenuEventRef);
}
}
}

View file

@ -0,0 +1,130 @@
import { App, Menu, TFile } from 'obsidian';
import { NoteStatusSettings } from 'models/types';
import { StatusService } from 'services/status-service';
import { StatusDropdown } from 'components/status-dropdown';
import { ExplorerIntegration } from 'integrations/explorer/explorer-integration';
/**
* Gestiona los menús contextuales para cambios de estado
*/
export class StatusContextMenu {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private statusDropdown: StatusDropdown;
private explorerIntegration: ExplorerIntegration;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
statusDropdown: StatusDropdown,
explorerIntegration: ExplorerIntegration
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.statusDropdown = statusDropdown;
this.explorerIntegration = explorerIntegration;
}
/**
* Actualiza la configuración
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
/**
* Añade ítem de menú para cambiar estado de un archivo
*/
public addStatusMenuItemToSingleFile(menu: Menu, file: TFile, onClick: (file: TFile) => void): void {
menu.addItem(item =>
item
.setTitle('Change status')
.setIcon('tag')
.onClick(() => onClick(file))
);
}
/**
* Añade ítem de menú para cambiar estado de múltiples archivos
*/
public addStatusMenuItemToBatch(menu: Menu, files: TFile[], onClick: (files: TFile[]) => void): void {
menu.addItem(item =>
item
.setTitle('Change status')
.setIcon('tag')
.onClick(() => onClick(files))
);
}
/**
* Muestra el menú contextual para cambiar estado de uno o más archivos
* @param files Archivos a los que cambiar el estado
* @param position Posición opcional para mostrar el menú
*/
public showForFiles(files: TFile[], position?: { x: number; y: number }): void {
if (files.length === 0) return;
if (files.length === 1) {
this.showForSingleFile(files[0], position);
} else {
this.showForMultipleFiles(files, position);
}
}
/**
* Muestra el menú contextual para un solo archivo
* @param file Archivo al que cambiar el estado
* @param position Posición opcional para mostrar el menú
*/
private showForSingleFile(file: TFile, position?: { x: number; y: number }): void {
if (!(file instanceof TFile) || file.extension !== 'md') return;
this.statusDropdown.openStatusDropdown({
position,
files: [file]
});
}
/**
* Muestra el menú contextual para múltiples archivos
* @param files Archivos a los que cambiar el estado
* @param position Posición opcional para mostrar el menú
*/
private showForMultipleFiles(files: TFile[], position?: { x: number; y: number }): void {
const menu = new Menu();
// Elemento de información (deshabilitado)
menu.addItem((item) => {
item.setTitle(`Update ${files.length} files`)
.setDisabled(true);
return item;
});
// Opción para gestionar estados
menu.addItem((item) =>
item
.setTitle('Manage statuses...')
.setIcon('tag')
.onClick(() => {
this.statusDropdown.openStatusDropdown({
position,
files
});
})
);
// Mostrar el menú en la posición adecuada
if (position) {
menu.showAtPosition(position);
} else {
// Use a centered position
menu.showAtPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 3
});
}
}
}

View file

@ -0,0 +1,80 @@
import { App, Editor, MarkdownView, Menu, TFile } from 'obsidian';
import { NoteStatusSettings } from '../../models/types';
import { StatusService } from 'services/status-service';
import { StatusDropdown } from 'components/status-dropdown';
export class EditorIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private statusDropdown: StatusDropdown;
private editorMenuRef: any;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
statusDropdown: StatusDropdown
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.statusDropdown = statusDropdown;
}
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
public registerEditorMenus(): void {
this.editorMenuRef = (menu: Menu, editor: Editor, view: MarkdownView) => {
if (view.file) {
this.addStatusMenuItems(menu, editor, view);
}
};
this.app.workspace.on('editor-menu', this.editorMenuRef);
}
private addStatusMenuItems(menu: Menu, editor: Editor, view: MarkdownView): void {
menu.addItem(item =>
item
.setTitle('Change note status')
.setIcon('tag')
.onClick(() => {
if (view.file) {
this.statusDropdown.openStatusDropdown({
files: [view.file],
editor,
view
});
}
})
);
// Only show insert metadata if it doesn't exist
if (view.file) {
const statuses = this.statusService.getFileStatuses(view.file);
if (statuses.length === 1 && statuses[0] === 'unknown') {
menu.addItem(item =>
item
.setTitle('Insert status metadata')
.setIcon('plus-circle')
.onClick(() => {
this.insertStatusMetadata(editor);
})
);
}
}
}
public insertStatusMetadata(editor: Editor): void {
this.statusService.insertStatusMetadataInEditor(editor);
}
public unload(): void {
if (this.editorMenuRef) {
this.app.workspace.off('editor-menu', this.editorMenuRef);
}
}
}

View file

@ -0,0 +1,2 @@
export { EditorIntegration } from './editor-integration';
export { ToolbarIntegration } from './toolbar-integration';

View file

@ -0,0 +1,118 @@
import { App, MarkdownView } from 'obsidian';
import { NoteStatusSettings } from '../../models/types';
import { StatusService } from '../../services/status-service';
import { StatusDropdown } from '../../components/status-dropdown';
import { ToolbarButton } from 'components/toolbar-button';
/**
* Gestiona la integración con la barra de herramientas del editor
*/
export class ToolbarIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private statusDropdown: StatusDropdown;
private buttonView: ToolbarButton;
private buttonElement: HTMLElement | null = null;
private currentLeafId: string | null = null;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
statusDropdown: StatusDropdown
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.statusDropdown = statusDropdown;
this.buttonView = new ToolbarButton(settings, statusService);
}
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.buttonView.updateSettings(settings);
this.updateStatusDisplay([]);
}
public addToolbarButtonToActiveLeaf(statuses?: string[]): void {
const activeLeaf = this.app.workspace.activeLeaf;
if (!activeLeaf?.view || !(activeLeaf.view instanceof MarkdownView)) return;
const leafId = (activeLeaf as any).id || activeLeaf.view.containerEl.id;
// Only recreate button if we're on a different leaf or button doesn't exist
if (this.currentLeafId !== leafId || !this.buttonElement || !this.isButtonInDOM()) {
this.recreateButton(activeLeaf.view, leafId);
}
this.updateButtonDisplay(statuses);
}
private recreateButton(view: MarkdownView, leafId: string): void {
const toolbarContainer = view.containerEl.querySelector('.view-header .view-actions');
if (!toolbarContainer) return;
// Remove old button if it exists
this.removeToolbarButton();
// Create new button
this.buttonElement = this.buttonView.createElement();
this.buttonElement.addEventListener('click', this.handleButtonClick.bind(this));
if (toolbarContainer.firstChild) {
toolbarContainer.insertBefore(this.buttonElement, toolbarContainer.firstChild);
} else {
toolbarContainer.appendChild(this.buttonElement);
}
this.currentLeafId = leafId;
}
private isButtonInDOM(): boolean {
return this.buttonElement?.isConnected === true;
}
private removeToolbarButton(): void {
this.app.workspace.iterateAllLeaves(leaf => {
if (leaf.view instanceof MarkdownView) {
const buttons = leaf.view.containerEl.querySelectorAll('.note-status-toolbar-button');
buttons.forEach(button => button.remove());
}
});
this.buttonElement = null;
this.currentLeafId = null;
}
private updateButtonDisplay(overrideStatuses?: string[]): void {
if (!this.buttonElement) return;
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
const statuses = overrideStatuses?.length ? overrideStatuses : this.statusService.getFileStatuses(activeFile);
this.buttonView.updateDisplay(statuses);
}
private handleButtonClick(e: MouseEvent): void {
e.stopPropagation();
e.preventDefault();
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
this.statusDropdown.openStatusDropdown({
target: this.buttonElement || undefined,
files: [activeFile]
});
}
public updateStatusDisplay(statuses: string[]): void {
this.updateButtonDisplay(statuses);
}
public unload(): void {
// this.buttonView.destroy();
this.removeToolbarButton();
}
}

View file

@ -0,0 +1,350 @@
import { App, TFile, setTooltip, debounce } from 'obsidian';
import { NoteStatusSettings, FileExplorerView } from '../../models/types';
import { StatusService } from 'services/status-service';
/**
* Manages the logic for file explorer status integration
*/
export class ExplorerIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private ui: ExplorerIntegrationUI;
private iconUpdateQueue = new Set<string>();
private isProcessingQueue = false;
private debouncedUpdateAll: ReturnType<typeof debounce>;
private fileItemsWasUndefined = false;
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.ui = new ExplorerIntegrationUI(app, settings, statusService, () => {
this.fileItemsWasUndefined = true;
});
this.debouncedUpdateAll = debounce(this.processUpdateQueue.bind(this), 100, true);
// Listen for layout changes to detect when file explorer becomes available
this.app.workspace.on('layout-change', () => {
this.checkFileExplorerAvailability();
});
}
/**
* Check if file explorer became available after being unavailable
*/
private checkFileExplorerAvailability(): void {
if (!this.settings.showStatusIconsInExplorer) return;
const fileExplorer = this.ui.findFileExplorerView();
if (fileExplorer?.fileItems && this.fileItemsWasUndefined) {
this.fileItemsWasUndefined = false;
setTimeout(() => this.updateAllFileExplorerIcons(), 100);
}
}
/**
* Updates settings and refreshes UI if necessary
*/
public updateSettings(settings: NoteStatusSettings): void {
const shouldRefreshIcons =
this.settings.showStatusIconsInExplorer !== settings.showStatusIconsInExplorer ||
this.settings.hideUnknownStatusInExplorer !== settings.hideUnknownStatusInExplorer;
this.settings = {...settings};
this.ui.updateSettings(settings);
if (shouldRefreshIcons) {
this.ui.removeAllFileExplorerIcons();
if (settings.showStatusIconsInExplorer) {
setTimeout(() => this.updateAllFileExplorerIcons(), 50);
}
} else if (settings.showStatusIconsInExplorer) {
this.updateAllFileExplorerIcons();
}
}
/**
* Updates icons for a specific file
*/
public updateFileExplorerIcons(file: TFile): void {
if (!file || !this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
const activeFile = this.app.workspace.getActiveFile();
if (activeFile?.path === file.path) {
this.ui.updateSingleFileIconDirectly(file, this.statusService);
}
this.queueFileUpdate(file);
}
/**
* Updates all icons in the explorer
*/
public updateAllFileExplorerIcons(): void {
if (!this.settings.showStatusIconsInExplorer) {
this.ui.removeAllFileExplorerIcons();
return;
}
this.processFilesInBatches();
}
/**
* Gets selected files from the explorer
*/
public getSelectedFiles(): TFile[] {
return this.ui.getSelectedFiles();
}
/**
* Queue a file for icon update
*/
private queueFileUpdate(file: TFile): void {
if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
this.iconUpdateQueue.add(file.path);
this.debouncedUpdateAll();
}
/**
* Process the update queue
*/
private async processUpdateQueue(): Promise<void> {
if (this.isProcessingQueue || this.iconUpdateQueue.size === 0) return;
this.isProcessingQueue = true;
try {
const fileExplorerView = this.ui.findFileExplorerView();
if (!fileExplorerView || !fileExplorerView.fileItems) {
this.fileItemsWasUndefined = true;
setTimeout(() => this.debouncedUpdateAll(), 200);
return;
}
const allPaths = Array.from(this.iconUpdateQueue);
const batchSize = 50;
for (let i = 0; i < allPaths.length; i += batchSize) {
await this.processBatch(allPaths.slice(i, i + batchSize), fileExplorerView);
if (i + batchSize < allPaths.length) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
} catch (error) {
console.error('Note Status: Error processing file update queue', error);
} finally {
this.isProcessingQueue = false;
if (this.iconUpdateQueue.size > 0) {
this.debouncedUpdateAll();
}
}
}
/**
* Process a batch of files
*/
private async processBatch(paths: string[], fileExplorerView: any): Promise<void> {
for (const path of paths) {
const file = this.app.vault.getFileByPath(path);
if (file instanceof TFile) {
this.ui.updateSingleFileIcon(file, fileExplorerView, this.statusService);
}
this.iconUpdateQueue.delete(path);
}
}
/**
* Process files in batches
*/
private async processFilesInBatches(): Promise<void> {
const files = this.app.vault.getMarkdownFiles();
const batchSize = 100;
for (let i = 0; i < files.length; i += batchSize) {
files.slice(i, i + batchSize).forEach(file => this.queueFileUpdate(file));
if (i + batchSize < files.length) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
/**
* Cleanup when unloading the plugin
*/
public unload(): void {
this.ui.removeAllFileExplorerIcons();
this.debouncedUpdateAll.cancel();
}
}
/**
* Manages UI operations for file explorer icons
*/
export class ExplorerIntegrationUI {
private app: App;
private settings: NoteStatusSettings;
private onFileItemsUndefined: () => void;
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService, onFileItemsUndefined: () => void) {
this.app = app;
this.settings = settings;
this.onFileItemsUndefined = onFileItemsUndefined;
}
/**
* Updates the settings
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
/**
* Finds the file explorer view
*/
public findFileExplorerView(): FileExplorerView | null {
const leaf = this.app.workspace.getLeavesOfType('file-explorer')[0];
if (leaf?.view) return leaf.view as FileExplorerView;
for (const leaf of this.app.workspace.getLeavesOfType('')) {
if (leaf.view && 'fileItems' in leaf.view) {
return leaf.view as FileExplorerView;
}
}
return null;
}
/**
* Updates a single file icon
*/
public updateSingleFileIcon(file: TFile, fileExplorerView: FileExplorerView, statusService: StatusService): void {
if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
try {
// Check if fileItems is initialized
if (!fileExplorerView.fileItems) {
this.onFileItemsUndefined();
return;
}
const fileItem = fileExplorerView.fileItems[file.path];
if (!fileItem) return;
const titleEl = fileItem.titleEl || fileItem.selfEl;
if (!titleEl) return;
const statuses = statusService.getFileStatuses(file);
this.removeExistingIcons(titleEl);
if (this.shouldSkipIcon(statuses)) return;
this.addStatusIcons(titleEl, statuses, statusService);
} catch (error) {
console.error(`Note Status: Error updating icon for ${file.path}`, error);
}
}
/**
* Updates the icon for the active file directly
*/
public updateSingleFileIconDirectly(file: TFile, statusService: StatusService): void {
const fileExplorer = this.findFileExplorerView();
if (fileExplorer) {
this.updateSingleFileIcon(file, fileExplorer, statusService);
}
}
/**
* Removes all file explorer icons
*/
public removeAllFileExplorerIcons(): void {
const fileExplorer = this.findFileExplorerView();
if (!fileExplorer?.fileItems) return;
Object.values(fileExplorer.fileItems).forEach(fileItem => {
const titleEl = fileItem.titleEl || fileItem.selfEl;
if (titleEl) this.removeExistingIcons(titleEl);
});
}
/**
* Gets the currently selected files
*/
public getSelectedFiles(): TFile[] {
const fileExplorer = this.findFileExplorerView();
if (!fileExplorer?.fileItems) return [];
return Object.values(fileExplorer.fileItems)
.filter(item =>
item.el?.classList.contains('is-selected') &&
item.file instanceof TFile &&
item.file.extension === 'md')
.map(item => item.file as TFile);
}
/**
* Checks if the icon should be skipped based on status
*/
private shouldSkipIcon(statuses: string[]): boolean {
return this.settings.hideUnknownStatusInExplorer &&
statuses.length === 1 &&
statuses[0] === 'unknown';
}
/**
* Removes existing status icons
*/
private removeExistingIcons(element: HTMLElement): void {
const iconSelectors = '.note-status-icon, .note-status-icon-container';
element.querySelectorAll(iconSelectors).forEach(icon => {
if (icon.classList.contains('note-status-icon') ||
icon.classList.contains('note-status-icon-container')) {
icon.remove();
}
});
}
/**
* Adds status icons to an element
*/
private addStatusIcons(titleEl: HTMLElement, statuses: string[], statusService: StatusService): void {
const iconContainer = document.createElement('span');
iconContainer.className = 'note-status-icon-container';
if (this.settings.useMultipleStatuses && statuses.length > 0 && statuses[0] !== 'unknown') {
statuses.forEach(status => this.addSingleStatusIcon(iconContainer, status, statusService));
} else {
const primaryStatus = statuses[0] || 'unknown';
if (primaryStatus !== 'unknown' || !this.settings.hideUnknownStatusInExplorer) {
this.addSingleStatusIcon(iconContainer, primaryStatus, statusService);
}
}
if (iconContainer.childElementCount > 0) {
titleEl.appendChild(iconContainer);
}
}
/**
* Adds a single status icon
*/
private addSingleStatusIcon(container: HTMLElement, status: string, statusService: StatusService): void {
const iconEl = document.createElement('span');
iconEl.className = `note-status-icon nav-file-tag status-${status}`;
iconEl.textContent = statusService.getStatusIcon(status);
const statusObj = statusService.getAllStatuses().find(s => s.name === status);
const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}`: status;
setTooltip(iconEl, tooltipValue);
container.appendChild(iconEl);
}
}

View file

@ -0,0 +1 @@
export { ExplorerIntegration } from './explorer-integration';

View file

@ -0,0 +1 @@
export { MetadataIntegration } from './metadata-integration';

View file

@ -0,0 +1,71 @@
import { App, TFile } from 'obsidian';
import { NoteStatusSettings } from '../../models/types';
import { ExplorerIntegration } from '../explorer/explorer-integration';
import { StatusService } from 'services/status-service';
export class MetadataIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private explorerIntegration: ExplorerIntegration;
private metadataChangedRef: any;
private metadataResolvedRef: any;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
explorerIntegration: ExplorerIntegration
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.explorerIntegration = explorerIntegration;
}
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
public registerMetadataEvents(): void {
this.metadataChangedRef = (file: TFile) => {
if (file instanceof TFile && file.extension === 'md') {
this.handleMetadataChanged(file);
}
};
this.metadataResolvedRef = () => {
setTimeout(() => {
if (this.settings.showStatusIconsInExplorer) {
this.explorerIntegration.updateAllFileExplorerIcons();
}
}, 500);
};
this.app.metadataCache.on('changed', this.metadataChangedRef);
this.app.metadataCache.on('resolved', this.metadataResolvedRef);
}
private handleMetadataChanged(file: TFile): void {
if (this.settings.showStatusIconsInExplorer) {
this.explorerIntegration.updateFileExplorerIcons(file);
}
const activeFile = this.app.workspace.getActiveFile();
if (activeFile?.path === file.path) {
const statuses = this.statusService.getFileStatuses(file);
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses, file: file.path }
}));
}
}
public unload(): void {
if (this.metadataChangedRef) {
this.app.metadataCache.off('changed', this.metadataChangedRef);
}
if (this.metadataResolvedRef) {
this.app.metadataCache.off('resolved', this.metadataResolvedRef);
}
}
}

View file

@ -0,0 +1,119 @@
import { App } from 'obsidian';
import { Status } from '../../models/types';
import NoteStatus from 'main';
import { StatusService } from 'services/status-service';
import { NoteStatusSettingsUI, SettingsUICallbacks } from './settings-ui';
/**
* Controller for settings UI, handles business logic and plugin state updates
*/
export class NoteStatusSettingsController implements SettingsUICallbacks {
private app: App;
private plugin: NoteStatus;
private statusService: StatusService;
private ui: NoteStatusSettingsUI;
constructor(app: App, plugin: NoteStatus, statusService: StatusService) {
this.app = app;
this.plugin = plugin;
this.statusService = statusService;
this.ui = new NoteStatusSettingsUI(this);
}
/**
* Renders the settings interface
*/
display(containerEl: HTMLElement): void {
this.ui.render(containerEl, this.plugin.settings);
}
/**
* Handles template enable/disable toggle
*/
async onTemplateToggle(templateId: string, enabled: boolean): Promise<void> {
if (enabled) {
if (!this.plugin.settings.enabledTemplates.includes(templateId)) {
this.plugin.settings.enabledTemplates.push(templateId);
}
} else {
this.plugin.settings.enabledTemplates = this.plugin.settings.enabledTemplates.filter(
(id: string) => id !== templateId
);
}
await this.plugin.saveSettings();
}
/**
* Handles general setting changes
*/
async onSettingChange(key: string, value: any): Promise<void> {
(this.plugin.settings as any)[key] = value;
await this.plugin.saveSettings();
}
/**
* Handles custom status field changes
*/
async onCustomStatusChange(index: number, field: string, value: any): Promise<void> {
const status = this.plugin.settings.customStatuses[index];
if (!status) return;
if (field === 'name') {
const oldName = status.name;
status.name = value;
if (oldName !== status.name) {
this.plugin.settings.statusColors[status.name] =
this.plugin.settings.statusColors[oldName];
delete this.plugin.settings.statusColors[oldName];
}
} else if (field === 'color') {
this.plugin.settings.statusColors[status.name] = value;
} else {
(status as any)[field] = value;
}
await this.plugin.saveSettings();
}
/**
* Handles custom status removal
*/
async onCustomStatusRemove(index: number): Promise<void> {
const status = this.plugin.settings.customStatuses[index];
if (!status) return;
this.plugin.settings.customStatuses.splice(index, 1);
delete this.plugin.settings.statusColors[status.name];
await this.plugin.saveSettings();
// Re-render the custom statuses section
const statusList = document.querySelector('.custom-status-list') as HTMLElement;
if (statusList) {
this.ui.renderCustomStatuses(statusList, this.plugin.settings);
}
}
/**
* Handles adding new custom status
*/
async onCustomStatusAdd(): Promise<void> {
const newStatus: Status = {
name: `status${this.plugin.settings.customStatuses.length + 1}`,
icon: '⭐'
};
this.plugin.settings.customStatuses.push(newStatus);
this.plugin.settings.statusColors[newStatus.name] = '#ffffff';
await this.plugin.saveSettings();
// Re-render the custom statuses section
const statusList = document.querySelector('.custom-status-list') as HTMLElement;
if (statusList) {
this.ui.renderCustomStatuses(statusList, this.plugin.settings);
}
}
}

View file

@ -0,0 +1,23 @@
import { App, PluginSettingTab } from 'obsidian';
import NoteStatus from 'main';
import { StatusService } from 'services/status-service';
import { NoteStatusSettingsController } from './settings-controller';
/**
* Settings tab for the Note Status plugin - delegates to controller
*/
export class NoteStatusSettingTab extends PluginSettingTab {
private controller: NoteStatusSettingsController;
constructor(app: App, plugin: NoteStatus, statusService: StatusService) {
super(app, plugin);
this.controller = new NoteStatusSettingsController(app, plugin, statusService);
}
/**
* Displays the settings interface
*/
display(): void {
this.controller.display(this.containerEl);
}
}

View file

@ -0,0 +1,235 @@
import { Setting } from 'obsidian';
import { Status } from '../../models/types';
import { PREDEFINED_TEMPLATES } from '../../constants/status-templates';
/**
* Callbacks interface for settings UI interactions
*/
export interface SettingsUICallbacks {
/** Handle template enable/disable toggle */
onTemplateToggle: (templateId: string, enabled: boolean) => Promise<void>;
/** Handle general setting changes */
onSettingChange: (key: string, value: any) => Promise<void>;
/** Handle custom status field changes */
onCustomStatusChange: (index: number, field: string, value: any) => Promise<void>;
/** Handle custom status removal */
onCustomStatusRemove: (index: number) => Promise<void>;
/** Handle adding new custom status */
onCustomStatusAdd: () => Promise<void>;
}
/**
* Pure UI component for rendering settings interface
*/
export class NoteStatusSettingsUI {
private callbacks: SettingsUICallbacks;
constructor(callbacks: SettingsUICallbacks) {
this.callbacks = callbacks;
}
/**
* Renders the complete settings interface
*/
render(containerEl: HTMLElement, settings: any): void {
containerEl.empty();
this.renderTemplateSettings(containerEl, settings);
this.renderUISettings(containerEl, settings);
this.renderTagSettings(containerEl, settings);
this.renderCustomStatusSettings(containerEl, settings);
}
/**
* Renders the status templates section
*/
private renderTemplateSettings(containerEl: HTMLElement, settings: any): void {
new Setting(containerEl).setName('Status templates').setHeading();
containerEl.createEl('p', {
text: 'Enable predefined templates to quickly add common status workflows',
cls: 'setting-item-description'
});
const templatesContainer = containerEl.createDiv({ cls: 'templates-container' });
PREDEFINED_TEMPLATES.forEach(template => {
const templateEl = templatesContainer.createDiv({ cls: 'template-item' });
const headerEl = templateEl.createDiv({ cls: 'template-header' });
const isEnabled = settings.enabledTemplates.includes(template.id);
const checkbox = headerEl.createEl('input', {
type: 'checkbox',
cls: 'template-checkbox'
});
checkbox.checked = isEnabled;
checkbox.addEventListener('change', () => {
this.callbacks.onTemplateToggle(template.id, checkbox.checked);
});
headerEl.createEl('span', {
text: template.name,
cls: 'template-name'
});
templateEl.createEl('div', {
text: template.description,
cls: 'template-description'
});
const statusesEl = templateEl.createDiv({ cls: 'template-statuses' });
template.statuses.forEach(status => {
const statusEl = statusesEl.createEl('div', { cls: 'template-status-chip' });
const colorDot = statusEl.createEl('span', { cls: 'status-color-dot' });
colorDot.style.setProperty('--dot-color', status.color || '#ffffff');
statusEl.createSpan({ text: `${status.icon} ${status.name}` });
});
});
}
/**
* Renders the UI display settings section
*/
private renderUISettings(containerEl: HTMLElement, settings: any): void {
new Setting(containerEl).setName('User interface').setHeading();
new Setting(containerEl)
.setName('Show status bar')
.setDesc('Display the status bar')
.addToggle(toggle => toggle
.setValue(settings.showStatusBar)
.onChange(value => this.callbacks.onSettingChange('showStatusBar', value)));
new Setting(containerEl)
.setName('Auto-hide status bar')
.setDesc('Hide the status bar when status is unknown')
.addToggle(toggle => toggle
.setValue(settings.autoHideStatusBar)
.onChange(value => this.callbacks.onSettingChange('autoHideStatusBar', value)));
new Setting(containerEl)
.setName('Show status icons in file explorer')
.setDesc('Display status icons in the file explorer')
.addToggle(toggle => toggle
.setValue(settings.showStatusIconsInExplorer)
.onChange(value => this.callbacks.onSettingChange('showStatusIconsInExplorer', value)));
new Setting(containerEl)
.setName('Hide unknown status in file explorer')
.setDesc('Hide status icons for files with unknown status in the file explorer')
.addToggle(toggle => toggle
.setValue(settings.hideUnknownStatusInExplorer || false)
.onChange(value => this.callbacks.onSettingChange('hideUnknownStatusInExplorer', value)));
new Setting(containerEl)
.setName('Default to compact view')
.setDesc('Start the status pane in compact view by default')
.addToggle(toggle => toggle
.setValue(settings.compactView || false)
.onChange(value => this.callbacks.onSettingChange('compactView', value)));
new Setting(containerEl)
.setName('Exclude unassigned notes from status pane')
.setDesc('Improves performance by excluding notes with no assigned status from the status pane. Recommended for large vaults.')
.addToggle(toggle => toggle
.setValue(settings.excludeUnknownStatus || false)
.onChange(value => this.callbacks.onSettingChange('excludeUnknownStatus', value)));
}
/**
* Renders the status tag configuration section
*/
private renderTagSettings(containerEl: HTMLElement, settings: any): void {
new Setting(containerEl).setName('Status tag').setHeading();
new Setting(containerEl)
.setName('Enable multiple statuses')
.setDesc('Allow notes to have multiple statuses at the same time')
.addToggle(toggle => toggle
.setValue(settings.useMultipleStatuses)
.onChange(value => this.callbacks.onSettingChange('useMultipleStatuses', value)));
new Setting(containerEl)
.setName('Status tag prefix')
.setDesc('The YAML frontmatter tag name used for status (default: obsidian-note-status)')
.addText(text => text
.setValue(settings.tagPrefix)
.onChange(value => {
if (value.trim()) {
this.callbacks.onSettingChange('tagPrefix', value.trim());
}
}));
new Setting(containerEl)
.setName('Strict status validation')
.setDesc('Only show statuses that are defined in templates or custom statuses. ⚠️ WARNING: When enabled, any unknown statuses will be automatically removed when modifying file statuses.')
.addToggle(toggle => toggle
.setValue(settings.strictStatuses || false)
.onChange(value => this.callbacks.onSettingChange('strictStatuses', value)));
}
/**
* Renders the custom status management section
*/
private renderCustomStatusSettings(containerEl: HTMLElement, settings: any): void {
new Setting(containerEl).setName('Custom statuses').setHeading();
new Setting(containerEl)
.setName('Use only custom statuses')
.setDesc('Ignore template statuses and use only the custom statuses defined below')
.addToggle(toggle => toggle
.setValue(settings.useCustomStatusesOnly || false)
.onChange(value => this.callbacks.onSettingChange('useCustomStatusesOnly', value)));
const statusList = containerEl.createDiv({ cls: 'custom-status-list' });
this.renderCustomStatuses(statusList, settings);
new Setting(containerEl)
.setName('Add new status')
.setDesc('Add a custom status with a name, icon, and color')
.addButton(button => button
.setButtonText('Add Status')
.setCta()
.onClick(() => this.callbacks.onCustomStatusAdd()));
}
/**
* Renders the list of custom statuses with edit controls
*/
renderCustomStatuses(statusList: HTMLElement, settings: any): void {
statusList.empty();
settings.customStatuses.forEach((status: Status, index: number) => {
const setting = new Setting(statusList)
.setName(status.name)
.setClass('status-item');
setting.addText(text => text
.setPlaceholder('Name')
.setValue(status.name)
.onChange(value => this.callbacks.onCustomStatusChange(index, 'name', value || 'unnamed')));
setting.addText(text => text
.setPlaceholder('Icon')
.setValue(status.icon)
.onChange(value => this.callbacks.onCustomStatusChange(index, 'icon', value || '❓')));
setting.addColorPicker(colorPicker => colorPicker
.setValue(settings.statusColors[status.name] || '#ffffff')
.onChange(value => this.callbacks.onCustomStatusChange(index, 'color', value)));
setting.addText(text => text
.setPlaceholder('Description')
.setValue(status.description || '')
.onChange(value => this.callbacks.onCustomStatusChange(index, 'description', value)));
setting.addButton(button => button
.setButtonText('Remove')
.setClass('status-remove-button')
.setWarning()
.onClick(() => this.callbacks.onCustomStatusRemove(index)));
});
}
}

View file

@ -0,0 +1 @@
export { WorkspaceIntegration } from './workspace-integration';

View file

@ -0,0 +1,124 @@
import { App, TFile, WorkspaceLeaf } from 'obsidian';
import { NoteStatusSettings } from '../../models/types';
import { ToolbarIntegration } from '../editor/toolbar-integration';
import { StatusService } from 'services/status-service';
/**
* Gestiona la integración con el workspace de Obsidian
*/
export class WorkspaceIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private toolbarIntegration: ToolbarIntegration;
private lastActiveFile: TFile | null = null;
private fileOpenEventRef: any;
private activeLeafChangeEventRef: any;
private layoutChangeEventRef: any;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
toolbarIntegration: ToolbarIntegration
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.toolbarIntegration = toolbarIntegration;
}
/**
* Actualiza la configuración
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
/**
* Registra eventos del workspace
*/
public registerWorkspaceEvents(): void {
this.fileOpenEventRef = (file: any) => {
if (file instanceof TFile) {
this.handleFileOpen(file);
}
};
this.activeLeafChangeEventRef = (leaf: WorkspaceLeaf) => {
this.handleActiveLeafChange(leaf);
};
this.layoutChangeEventRef = () => {
this.handleLayoutChange();
};
this.app.workspace.on('file-open', this.fileOpenEventRef);
this.app.workspace.on('active-leaf-change', this.activeLeafChangeEventRef);
this.app.workspace.on('layout-change', this.layoutChangeEventRef);
}
/**
* Maneja apertura de archivo
*/
private handleFileOpen(file: TFile): void {
// Añade el botón de la barra de herramientas
this.toolbarIntegration.addToolbarButtonToActiveLeaf();
// Actualiza estado
this.propagateNoteStatusChange(file);
}
/**
* Maneja cambio de hoja activa
*/
private handleActiveLeafChange(leaf: WorkspaceLeaf): void {
// Añade el botón de la barra de herramientas
this.toolbarIntegration.addToolbarButtonToActiveLeaf();
const activeFile = this.app.workspace.getActiveFile();
// Solo actualiza si el archivo realmente cambió
// if (this.lastActiveFile?.path !== activeFile?.path) {
// this.lastActiveFile = activeFile;
// this.propagateNoteStatusChange();
// }
}
/**
* Maneja y propaga el cambio de layout
*/
private handleLayoutChange(): void {
window.dispatchEvent(new CustomEvent('note-status:update-pane'));
}
/**
* Verifica y propaga el estado de la nota activa
*/
private propagateNoteStatusChange(file: TFile): void {
try {
const activeFile = this.app.workspace.getActiveFile();
let fileStatuses: string[] = [];
if (activeFile && activeFile.extension === 'md') {
fileStatuses = this.statusService.getFileStatuses(activeFile)
}
// Dispara evento para que otros componentes se actualicen
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses: fileStatuses, file: file }
}));
} catch (error) {
console.error('Error checking note status:', error);
}
}
public unload(): void {
if (this.fileOpenEventRef) {
this.app.workspace.off('file-open', this.fileOpenEventRef);
}
if (this.activeLeafChangeEventRef) {
this.app.workspace.off('active-leaf-change', this.activeLeafChangeEventRef);
}
if (this.layoutChangeEventRef) {
this.app.workspace.off('layout-change', this.layoutChangeEventRef);
}
}
}

943
main.ts
View file

@ -1,753 +1,218 @@
import { Editor, MarkdownView, Notice, Plugin, TFile, addIcon, WorkspaceLeaf, debounce } from 'obsidian';
// Constants
import { ICONS } from './constants/icons';
import { DEFAULT_SETTINGS, DEFAULT_COLORS } from './constants/defaults';
// Types
import { Plugin, Notice } from 'obsidian';
import { DEFAULT_SETTINGS } from './constants/defaults';
import { NoteStatusSettings } from './models/types';
import { StatusService } from 'services/status-service';
import { StyleService } from 'services/style-service';
// Services
import { StatusService } from './services/status-service';
import { StyleService } from './services/style-service';
// Importar integraciones
import { ExplorerIntegration } from './integrations/explorer';
import { EditorIntegration, ToolbarIntegration } from './integrations/editor';
import { MetadataIntegration } from './integrations/metadata-cache';
import { WorkspaceIntegration } from './integrations/workspace';
import { FileContextMenuIntegration } from 'integrations/context-menu/file-context-menu-integration';
import { NoteStatusSettingTab } from 'integrations/settings/settings-tab';
import { CommandIntegration } from 'integrations/commands/command-integration';
// 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 { StatusContextMenu } from './ui/menus/status-context-menu';
// Importar vistas
import { StatusPaneViewController } from './views/status-pane-view';
// Settings
import { NoteStatusSettingTab } from './settings/settings-tab';
// Importar componentes UI
import { StatusBar } from 'components/status-bar';
import { StatusDropdown } from 'components/status-dropdown';
import { StatusContextMenu } from 'integrations/context-menu/status-context-menu';
/**
* Main plugin class for Note Status functionality
*/
export default class NoteStatus extends Plugin {
private boundSaveSettings: () => Promise<void>;
private boundCheckNoteStatus: () => void;
private boundRefreshDropdown: () => void;
private boundRefreshUI: () => void;
settings: NoteStatusSettings;
// Servicios
statusService: StatusService;
styleService: StyleService;
// Componentes UI
statusBar: StatusBar;
statusDropdown: StatusDropdown
// Integraciones
explorerIntegration: ExplorerIntegration;
fileContextMenuIntegration: FileContextMenuIntegration;
editorIntegration: EditorIntegration;
toolbarIntegration: ToolbarIntegration;
metadataIntegration: MetadataIntegration;
workspaceIntegration: WorkspaceIntegration;
commandIntegration: CommandIntegration;
settings: NoteStatusSettings;
statusService: StatusService;
styleService: StyleService;
statusBar: StatusBar;
statusDropdown: StatusDropdown;
explorerIntegration: ExplorerIntegration;
statusContextMenu: StatusContextMenu;
private statusPaneLeaf: WorkspaceLeaf | null = null;
private lastActiveFile: TFile | null = null;
statusPane: StatusPaneViewController;
// Debounced methods for better performance
private debouncedCheckNoteStatus = debounce(
() => this.checkNoteStatus(),
100,
true
);
private boundHandleStatusChanged: (event: CustomEvent) => void;
private debouncedUpdateExplorer = debounce(
() => this.explorerIntegration?.updateAllFileExplorerIcons(),
150
);
async onload() {
try {
await this.loadSettings();
this.initializeServices();
this.registerViews();
this.initializeUI();
this.initializeIntegrations();
this.setupCustomEvents();
} catch (error) {
console.error('Error loading Note Status plugin:', error);
new Notice('Error loading Note Status plugin. Check console for details.');
}
}
private debouncedUpdateStatusPane = debounce(
() => this.updateStatusPane(),
200
);
private async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
private hasShownErrorNotification = false;
private initializeServices() {
this.statusService = new StatusService(this.app, this.settings);
this.styleService = new StyleService(this.settings);
}
async onload() {
try {
// Load settings first before initializing other components
await this.loadSettings();
this.boundSaveSettings = this.saveSettings.bind(this);
this.boundCheckNoteStatus = this.checkNoteStatus.bind(this);
this.boundRefreshDropdown = () => this.statusDropdown?.render();
this.boundRefreshUI = () => this.checkNoteStatus();
// Register icons and essential services first
this.registerIcons();
this.statusService = new StatusService(this.app, this.settings);
this.styleService = new StyleService(this.settings);
// Register views and commands right away
this.registerViews();
this.registerCommands();
// Add settings tab
this.addSettingTab(new NoteStatusSettingTab(this.app, this));
// Set up custom events early
this.setupCustomEvents();
// Delay UI-heavy initialization until the layout is ready
this.app.workspace.onLayoutReady(async () => {
// Split initialization into phases for better performance
await this.initializeUIComponents();
});
} catch (error) {
console.error('Error loading Note Status plugin:', error);
new Notice('Error loading Note Status plugin. Check console for details.');
}
}
private async initializeUIComponents(): Promise<void> {
// Initialize UI components
this.statusBar = new StatusBar(this.addStatusBarItem(), this.settings, this.statusService);
this.statusDropdown = new StatusDropdown(this.app, this.settings, this.statusService);
// Initialize explorer integration with a slight delay
setTimeout(() => {
this.explorerIntegration = new ExplorerIntegration(this.app, this.settings, this.statusService);
this.statusContextMenu = new StatusContextMenu(
this.app,
this.settings,
this.statusService,
this.statusDropdown,
this.explorerIntegration
);
// Register events after explorer integration is ready
this.registerMenuHandlers();
this.registerEvents();
// Check status for active file only initially
this.checkNoteStatus();
// Update only active file icon initially for better performance
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && this.settings.showStatusIconsInExplorer) {
this.explorerIntegration.updateFileExplorerIcons(activeFile);
}
// Delay full explorer icon update to avoid startup lag
if (this.settings.showStatusIconsInExplorer) {
setTimeout(() => {
this.explorerIntegration.updateAllFileExplorerIcons();
}, 2000);
}
}, 300);
}
/**
* Initialize the plugin components
*/
private async initialize(): Promise<void> {
// Register custom icons
this.registerIcons();
// Initialize services
this.initializeServices();
// Initialize UI components
this.initializeUI();
// Register views, commands, and events
this.registerViews();
this.registerCommands();
this.registerMenuHandlers();
this.registerEvents();
// Set up custom events
this.setupCustomEvents();
// Add settings tab
this.addSettingTab(new NoteStatusSettingTab(this.app, this));
// 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<void> {
// 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(() => {
this.explorerIntegration.updateAllFileExplorerIcons();
}, 2000);
}
/**
* Register custom icons
*/
private registerIcons(): void {
Object.entries(ICONS).forEach(([name, svg]) => {
// Create a parser and get a DOM element for the SVG
const parser = new DOMParser();
const svgDoc = parser.parseFromString(svg, 'image/svg+xml');
const svgElement = svgDoc.documentElement;
// Get the SVG as a string using XMLSerializer
const serializer = new XMLSerializer();
const sanitizedSvg = serializer.serializeToString(svgElement);
if (name === 'statusPane') {
addIcon('status-pane', sanitizedSvg);
} else {
addIcon(`note-status-${name}`, sanitizedSvg);
}
});
}
/**
* Register views for status pane
*/
private registerViews(): void {
this.registerView('status-pane', (leaf) => {
this.statusPaneLeaf = leaf;
return new StatusPaneView(leaf, this);
});
// Add ribbon icon
this.addRibbonIcon('status-pane', 'Open status pane', () => {
this.openStatusPane();
});
}
/**
* Initialize plugin services
*/
private initializeServices(): void {
// These services depend on settings being loaded first
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, this.statusDropdown, this.explorerIntegration);
}
/**
* Set up custom events for status changes and UI updates
*/
private setupCustomEvents(): void {
// Listen for settings changes
window.addEventListener('note-status:settings-changed', this.boundSaveSettings);
// Listen for force refresh
window.addEventListener('note-status:force-refresh', () => {
try {
this.forceRefreshUI();
} catch (error) {
console.error('Error handling force refresh event:', error);
}
});
// 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);
}
});
// Listen for refresh dropdown
window.addEventListener('note-status:refresh-dropdown', this.boundRefreshDropdown);
// Listen for UI refresh
window.addEventListener('note-status:refresh-ui', this.boundRefreshUI);
}
/**
* 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!');
}
});
this.addCommand({
id: 'force-refresh-ui',
name: 'Force refresh user interface',
callback: () => this.forceRefreshUI()
});
// 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');
}
});
// Open status pane command
this.addCommand({
id: 'open-status-pane',
name: 'Open status pane',
callback: () => this.openStatusPane()
});
// 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 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]);
}
})
);
}
})
);
// Multiple files selection menu
this.registerEvent(
this.app.workspace.on('files-menu', (menu, files) => {
const mdFiles: TFile[] = [];
for (const file of files) {
if (file instanceof TFile && file.extension === 'md') {
mdFiles.push(file);
}
}
if (mdFiles.length > 0) {
menu.addItem((item) =>
item
.setTitle('Change status')
.setIcon('tag')
.onClick(() => {
this.statusContextMenu.showForFiles(mdFiles);
})
);
}
})
);
// 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))
);
}
})
);
}
/**
* 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) {
// First make sure the button exists
this.statusDropdown.addToolbarButtonToActiveLeaf();
// Then check status and update button
this.checkNoteStatus();
}
}));
// Editor change event - debounced to avoid performance issues
this.registerEvent(this.app.workspace.on('editor-change', () => {
this.debouncedCheckNoteStatus();
}));
// Add this for toolbar button persistence
this.registerEvent(
this.app.workspace.on('active-leaf-change', () => {
this.statusDropdown.addToolbarButtonToActiveLeaf();
// Update the status after adding the button to ensure icon is correct
this.checkNoteStatus();
})
);
// 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();
}
}));
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();
}
}));
// 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();
}
}));
this.registerEvent(this.app.vault.on('delete', () => {
this.debouncedUpdateExplorer();
this.debouncedUpdateStatusPane();
}));
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();
}
}));
// Metadata resolved event - when all files are indexed
this.registerEvent(
this.app.metadataCache.on('resolved', () => {
// When metadata cache is fully resolved, update all icons
setTimeout(() => this.explorerIntegration.updateAllFileExplorerIcons(), 500);
})
);
}
/**
* Check and update the status display for the active file
*/
checkNoteStatus(): void {
try {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile || !(activeFile instanceof TFile) || 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);
}
}
}
/**
* 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 instanceof TFile) || activeFile.extension !== 'md') {
return ['unknown'];
}
return this.statusService.getFileStatuses(activeFile);
} catch (error) {
console.error('Error getting current statuses:', error);
return ['unknown'];
}
}
/**
* Open the status pane
*/
public async openStatusPane(): Promise<void> {
try {
// Check if already open
const existing = this.app.workspace.getLeavesOfType('status-pane')[0];
if (existing) {
this.app.workspace.setActiveLeaf(existing);
return;
}
// Create a new leaf and show loading state
const leaf = this.app.workspace.getLeftLeaf(false);
if (leaf) {
await leaf.setViewState({ type: 'status-pane', active: true });
this.statusPaneLeaf = leaf;
}
} catch (error) {
console.error('Error opening status pane:', error);
new Notice('Error opening status pane. Check console for details.');
}
}
/**
* 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.');
}
}
/**
* Update the status pane
*/
async updateStatusPane(): Promise<void> {
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);
}
}
/**
* Load settings with error handling
*/
async loadSettings(): Promise<void> {
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();
}
}
/**
* 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;
}
}
// Also initialize colors from enabled templates if not present
this.initializeTemplateColors();
}
/**
* Initialize colors from templates
*/
private initializeTemplateColors(): void {
// Make sure settings are properly initialized
if (!this.settings) {
console.error('Settings not initialized properly');
return;
}
if (!this.settings.useCustomStatusesOnly && this.statusService) {
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;
}
}
}
}
/**
* Save settings with error handling
*/
async saveSettings(): Promise<void> {
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);
// 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);
}
}
/**
* 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.');
}
}
/**
* Clean up when the plugin is unloaded
*/
onunload(): void {
// 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
this.removeCustomEventListeners();
}
/**
* Remove custom event listeners
*/
private removeCustomEventListeners(): void {
window.removeEventListener('note-status:settings-changed', this.boundSaveSettings);
window.removeEventListener('note-status:status-changed', this.boundCheckNoteStatus);
window.removeEventListener('note-status:refresh-dropdown', this.boundRefreshDropdown);
window.removeEventListener('note-status:refresh-ui', this.boundRefreshUI);
private registerViews() {
// Register status pane view
this.registerView('status-pane', (leaf) => {
this.statusPane = new StatusPaneViewController(leaf, this);
return this.statusPane;
});
// Add ribbon icon
this.addRibbonIcon('tag', 'Open status pane', () => {
this.openStatusPane();
});
// Añadir pestaña de configuración
this.addSettingTab(new NoteStatusSettingTab(this.app, this, this.statusService));
}
private initializeIntegrations() {
this.explorerIntegration = new ExplorerIntegration(this.app, this.settings, this.statusService);
this.toolbarIntegration = new ToolbarIntegration(this.app, this.settings, this.statusService, this.statusDropdown);
const statusContextMenu = new StatusContextMenu(this.app, this.settings, this.statusService, this.statusDropdown, this.explorerIntegration);
this.fileContextMenuIntegration = new FileContextMenuIntegration(this.app, this.settings, this.statusService, this.explorerIntegration, statusContextMenu);
this.editorIntegration = new EditorIntegration(this.app, this.settings, this.statusService, this.statusDropdown);
this.commandIntegration = new CommandIntegration(
this.app,
this,
this.settings,
this.statusService,
this.statusDropdown
);
this.metadataIntegration = new MetadataIntegration(
this.app,
this.settings,
this.statusService,
this.explorerIntegration
);
this.workspaceIntegration = new WorkspaceIntegration(
this.app,
this.settings,
this.statusService,
this.toolbarIntegration
);
// Registrar eventos en cada integración
this.fileContextMenuIntegration.registerFileContextMenuEvents();
this.editorIntegration.registerEditorMenus();
this.commandIntegration.registerCommands();
this.metadataIntegration.registerMetadataEvents();
this.workspaceIntegration.registerWorkspaceEvents();
}
private setupCustomEvents() {
this.boundHandleStatusChanged = this.handleStatusChanged.bind(this);
window.addEventListener('note-status:status-changed', this.boundHandleStatusChanged);
}
private initializeUI() {
this.statusDropdown = new StatusDropdown(this.app, this.settings, this.statusService);
// Inicializar barra de estado
this.statusBar = new StatusBar(
this.addStatusBarItem(),
this.settings,
this.statusService
);
// Inicializar iconos del explorador (con retraso para evitar ralentizar el inicio)
if (this.settings.showStatusIconsInExplorer) {
setTimeout(() => {
this.explorerIntegration.updateAllFileExplorerIcons();
}, 2000);
}
}
private handleStatusChanged(event: CustomEvent) {
const { statuses, file } = event.detail;
// Actualizar barra de estado
this.statusBar.update(statuses);
// Actualizar toolbar
const activeFile = this.app.workspace.getActiveFile();
if (activeFile?.path === file) {
this.toolbarIntegration.updateStatusDisplay(statuses);
}
// Actualizar dropdown
this.statusDropdown.update(statuses);
// Actualizar status pane si está abierto
const statusPaneLeaf = this.app.workspace.getLeavesOfType('status-pane')[0];
if (statusPaneLeaf?.view && statusPaneLeaf.view instanceof StatusPaneViewController) {
(statusPaneLeaf.view as StatusPaneViewController).update();
}
// Actualizar explorador si es necesario
if (this.settings.showStatusIconsInExplorer && file) {
const fileObj = this.app.vault.getFileByPath(file);
if (fileObj) {
this.explorerIntegration.updateFileExplorerIcons(fileObj);
}
}
}
private async openStatusPane() {
await StatusPaneViewController.open(this.app);
}
async saveSettings() {
await this.saveData(this.settings);
// Actualizar servicios
this.statusService.updateSettings(this.settings);
this.styleService.updateSettings(this.settings);
// Actualizar integraciones
this.explorerIntegration.updateSettings(this.settings);
this.fileContextMenuIntegration.updateSettings(this.settings);
this.editorIntegration.updateSettings(this.settings);
this.metadataIntegration?.updateSettings(this.settings);
this.commandIntegration?.updateSettings(this.settings);
this.toolbarIntegration.updateSettings(this.settings);
this.workspaceIntegration.updateSettings(this.settings);
this.statusPane?.updateSettings(this.settings);
// Actualizar componentes UI
this.statusBar.updateSettings(this.settings);
this.statusDropdown.updateSettings(this.settings)
}
onunload() {
// Clean up event listeners
if (this.boundHandleStatusChanged) {
window.removeEventListener('note-status:status-changed', this.boundHandleStatusChanged);
}
// Clean up integrations
this.explorerIntegration?.unload();
this.toolbarIntegration?.unload();
this.fileContextMenuIntegration?.unload();
this.workspaceIntegration?.unload();
this.metadataIntegration?.unload();
this.editorIntegration?.unload();
// Clean up services
this.styleService?.unload();
// Clean up UI components
this.statusBar?.unload();
this.statusDropdown?.unload();
}
}

View file

@ -11,7 +11,7 @@
},
"isDesktopOnly": false,
"license": "MIT",
"icon": "status-pane",
"icon": "tag",
"repository": "https://github.com/devonthesofa/obsidian-note-status",
"issues": "https://github.com/devonthesofa/obsidian-note-status/issues",
"releaseNotes": "Initial release with status management, customizable UI, and file explorer integration.",

View file

@ -26,6 +26,7 @@ export interface NoteStatusSettings {
useCustomStatusesOnly: boolean; // Whether to use only custom statuses or include templates
useMultipleStatuses: boolean; // Whether to allow multiple statuses per note
tagPrefix: string; // Prefix for the status tag (default: 'status')
strictStatuses: boolean; // Whether to only show known statuses
excludeUnknownStatus: boolean; // Whether to exclude files with unknown status from the status pane
}
@ -39,4 +40,4 @@ export interface FileExplorerView extends View {
titleEl?: HTMLElement;
selfEl?: HTMLElement;
}>;
}
}

View file

@ -28,56 +28,38 @@ export class StatusService {
* Updates the combined list of all statuses (from templates and custom)
*/
private updateAllStatuses(): void {
// Start with custom statuses if not using templates exclusively
// Start with custom statuses
this.allStatuses = [...this.settings.customStatuses];
// Add statuses from enabled templates
// Add statuses from enabled templates if not using custom only
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())) {
const existingIndex = this.allStatuses.findIndex(s =>
s.name.toLowerCase() === status.name.toLowerCase());
if (existingIndex === -1) {
// Add new status
this.allStatuses.push(status);
} else {
this.updateExistingStatusColor(status);
} else if (status.color) {
// Update color in settings if it doesn't exist
if (!this.settings.statusColors[status.name]) {
this.settings.statusColors[status.name] = status.color;
}
}
}
}
}
/**
* 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;
}
}
}
/**
* 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;
return this.settings.enabledTemplates
.map(id => PREDEFINED_TEMPLATES.find(t => t.id === id))
.filter(Boolean)
.flatMap(template => template ? template.statuses : []);
}
/**
@ -89,63 +71,48 @@ export class StatusService {
/**
* 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) return ['unknown'];
if (cachedMetadata?.frontmatter) {
// Check for status using the configured tag prefix
const frontmatterStatus = cachedMetadata.frontmatter[this.settings.tagPrefix];
if (frontmatterStatus) {
if (Array.isArray(frontmatterStatus)) {
// Handle array format
this.processStatusArray(frontmatterStatus, statuses);
const frontmatterStatus = cachedMetadata.frontmatter[this.settings.tagPrefix];
if (!frontmatterStatus) return ['unknown'];
const statuses: string[] = [];
if (Array.isArray(frontmatterStatus)) {
for (const statusName of frontmatterStatus) {
if (this.settings.strictStatuses) {
this.addValidStatus(statusName.toString(), statuses);
} else {
// Handle single value format (string) - convert to array format internally
this.processSingleStatus(frontmatterStatus.toString(), statuses);
const cleanStatus = statusName.toString().trim();
if (cleanStatus) statuses.push(cleanStatus);
}
}
} else {
if (this.settings.strictStatuses) {
this.addValidStatus(frontmatterStatus.toString(), statuses);
} else {
const cleanStatus = frontmatterStatus.toString().trim();
if (cleanStatus) statuses.push(cleanStatus);
}
}
// Return 'unknown' if no statuses found
return statuses.length > 0 ? statuses : ['unknown'];
}
/**
* Process an array of statuses from frontmatter
* Add a status to the list if it's valid
*/
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 =>
private addValidStatus(statusName: string, targetStatuses: string[]): void {
const normalizedStatus = statusName.toLowerCase();
const matchingStatus = this.allStatuses.find(s =>
s.name.toLowerCase() === normalizedStatus);
if (matchingStatus) targetStatuses.push(matchingStatus.name);
}
/**
* 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);
}
}
/**
@ -158,167 +125,37 @@ export class StatusService {
return customStatus ? customStatus.icon : '❓';
}
/**
* Update the statuses of a note
* @param newStatuses Array of status names to set
* @param file Optional file to update, otherwise uses active file
*/
public async updateNoteStatuses(newStatuses: string[], file?: TFile): Promise<void> {
const targetFile = file || this.app.workspace.getActiveFile();
if (!targetFile || !(targetFile instanceof TFile) || targetFile.extension !== 'md') return;
// Use processFrontMatter instead of manual read/modify
await this.app.fileManager.processFrontMatter(targetFile, (frontmatter) => {
frontmatter[this.settings.tagPrefix] = newStatuses;
});
// Dispatch events for UI update
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses: newStatuses, file: targetFile.path }
}));
}
/**
* Legacy method to update a single status for backward compatibility
*/
public async updateNoteStatus(newStatus: string, file?: TFile): Promise<void> {
// Always store as an array even in single status mode
await this.updateNoteStatuses([newStatus], file);
}
/**
* Add a status to a note's existing statuses
*/
public async addNoteStatus(statusToAdd: string, file?: TFile): Promise<void> {
const targetFile = file || this.app.workspace.getActiveFile();
if (!targetFile || !(targetFile instanceof TFile) || 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 }
}));
}
/**
* Remove a status from a note's existing statuses
*/
public async removeNoteStatus(statusToRemove: string, file?: TFile): Promise<void> {
const targetFile = file || this.app.workspace.getActiveFile();
if (!targetFile || !(targetFile instanceof TFile) || 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 }
}));
}
/**
* Toggle a status on/off for a note
*/
public async toggleNoteStatus(statusToToggle: string, file?: TFile): Promise<void> {
const targetFile = file || this.app.workspace.getActiveFile();
if (!targetFile || targetFile.extension !== 'md') return;
const currentStatuses = this.getFileStatuses(targetFile);
let newStatuses: string[];
if (currentStatuses.includes(statusToToggle)) {
newStatuses = currentStatuses.filter(status => status !== statusToToggle);
} else {
// Filter out 'unknown' status when adding valid statuses
const filteredStatuses = currentStatuses.filter(s => s !== 'unknown');
newStatuses = [...filteredStatuses, statusToToggle];
}
await this.updateNoteStatuses(newStatuses, targetFile);
// Ensure UI updates
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses: newStatuses }
}));
}
/**
* 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
* @param showNotice Whether to show a notification (default: true)
*/
public async batchUpdateStatuses(
files: TFile[],
statusesToSet: string[],
mode: 'replace' | 'add' = 'replace',
showNotice = true
): Promise<void> {
if (files.length === 0) {
if (showNotice) {
new Notice('No files selected');
}
return;
}
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);
}
}
}
// Only show notice if explicitly requested and we're dealing with multiple files
if (showNotice && files.length > 1) {
const statusText = this.formatStatusText(statusesToSet);
new Notice(`Updated ${files.length} files 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();
public insertStatusMetadataInEditor(editor: Editor): void {
const content = editor.getValue();
const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/);
// Check if status metadata already exists
const statusTagRegex = new RegExp(`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`, 'm');
if (frontMatterMatch) {
const frontMatter = frontMatterMatch[1];
if (frontMatter.match(statusTagRegex)) {
// Status already exists, do nothing
return;
}
// Add to existing frontmatter
const defaultStatuses = ['unknown'];
const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
// Check if frontmatter exists
const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/);
if (frontMatterMatch) {
this.insertIntoExistingFrontmatter(editor, content, frontMatterMatch, statusMetadata);
} else {
this.createFrontmatterWithStatus(editor, content, statusMetadata);
this.insertIntoExistingFrontmatter(editor, content, frontMatterMatch, statusMetadata);
} else {
// No frontmatter, check if status exists in content somehow
if (content.match(statusTagRegex)) {
return;
}
// Create new frontmatter
const defaultStatuses = ['unknown'];
const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
this.createFrontmatterWithStatus(editor, content, statusMetadata);
}
}
/**
* Insert status metadata into existing frontmatter
@ -330,16 +167,11 @@ export class StatusService {
statusMetadata: string
): void {
const frontMatter = frontMatterMatch[1];
let updatedFrontMatter = frontMatter;
// 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}`;
}
const updatedFrontMatter = frontMatter.match(statusTagRegex)
? frontMatter.replace(statusTagRegex, statusMetadata)
: `${frontMatter}\n${statusMetadata}`;
const updatedContent = content.replace(/^---\n([\s\S]+?)\n---/, `---\n${updatedFrontMatter}\n---`);
editor.setValue(updatedContent);
@ -358,14 +190,11 @@ export class StatusService {
*/
public getMarkdownFiles(searchQuery = ''): TFile[] {
const files = this.app.vault.getMarkdownFiles();
if (!searchQuery) return files;
if (!searchQuery) {
return files;
}
return files.filter(file =>
file.basename.toLowerCase().includes(searchQuery.toLowerCase())
);
const lowerQuery = searchQuery.toLowerCase();
return files.filter(file =>
file.basename.toLowerCase().includes(lowerQuery));
}
/**
@ -375,22 +204,18 @@ export class StatusService {
const statusGroups: Record<string, TFile[]> = {};
// Initialize groups for all statuses
this.allStatuses.forEach(status => {
for (const status of this.allStatuses) {
statusGroups[status.name] = [];
});
// Ensure 'unknown' status is included
if (!statusGroups['unknown']) {
statusGroups['unknown'] = [];
}
// Ensure 'unknown' status exists
statusGroups['unknown'] = statusGroups['unknown'] || [];
// Get all markdown files and filter by search query
// Get and process all files matching the search query
const files = this.getMarkdownFiles(searchQuery);
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);
@ -400,4 +225,164 @@ export class StatusService {
return statusGroups;
}
}
/**
* Centralizes all status modification operations
*/
private async modifyNoteStatus(options: {
files: TFile | TFile[];
statuses: string | string[];
operation: 'set' | 'add' | 'remove' | 'toggle';
showNotice?: boolean;
}): Promise<void> {
const { operation, showNotice = true } = options;
const targetFiles = Array.isArray(options.files) ? options.files : [options.files];
const targetStatuses = Array.isArray(options.statuses) ? options.statuses : [options.statuses];
if (targetFiles.length === 0) {
if (showNotice) new Notice('No files selected');
return;
}
// Process each file
const updatePromises = targetFiles.map(async (file) => {
if (!file || file.extension !== 'md') return;
// Get current statuses for the file
const currentStatuses = this.getFileStatuses(file);
let newStatuses: string[] = [];
switch (operation) {
case 'set':
// Replace all statuses with the new ones
newStatuses = [...targetStatuses];
break;
case 'add':
// Add new statuses without duplicates
newStatuses = [...new Set([
...currentStatuses.filter(s => s !== 'unknown'),
...targetStatuses
])];
break;
case 'remove':
// Remove specified statuses
newStatuses = currentStatuses.filter(
status => !targetStatuses.includes(status)
);
break;
case 'toggle':
// Toggle each status (add if not present, remove if present)
newStatuses = [...currentStatuses];
for (const status of targetStatuses) {
if (currentStatuses.includes(status)) {
newStatuses = newStatuses.filter(s => s !== status);
} else {
newStatuses = [...newStatuses.filter(s => s !== 'unknown'), status];
}
}
break;
}
// Handle empty result (should revert to unknown)
if (newStatuses.length === 0) {
newStatuses = ['unknown'];
}
// Apply updates to frontmatter
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
frontmatter[this.settings.tagPrefix] = newStatuses;
});
this.notifyStatusChanged(newStatuses, file)
});
await Promise.all(updatePromises);
// Show notification for batch operations
if (showNotice && targetFiles.length > 1) {
const statusText = targetStatuses.length === 1
? targetStatuses[0]
: `${targetStatuses.length} statuses`;
const operationText = operation === 'set' ? 'updated' :
operation === 'add' ? 'added to' :
operation === 'remove' ? 'removed from' : 'toggled on';
new Notice(`${statusText} ${operationText} ${targetFiles.length} files`);
}
}
/**
* Handles UI-triggered status changes with appropriate logic based on context
* This is the central function that all UI components should call for status changes
*/
public async handleStatusChange(options: {
files: TFile | TFile[];
statuses: string | string[];
isMultipleSelection?: boolean;
allowMultipleStatuses?: boolean;
operation?: 'set' | 'add' | 'remove' | 'toggle';
showNotice?: boolean;
afterChange?: (updatedStatuses: string[]) => void;
}): Promise<void> {
const {
files,
statuses,
isMultipleSelection = false,
allowMultipleStatuses = this.settings.useMultipleStatuses,
operation: explicitOperation,
showNotice = isMultipleSelection,
afterChange
} = options;
const targetFiles = Array.isArray(files) ? files : [files];
const targetStatuses = Array.isArray(statuses) ? statuses : [statuses];
// Determine operation based on context if not explicitly specified
let operation: 'set' | 'add' | 'remove' | 'toggle';
if (explicitOperation) {
operation = explicitOperation;
} else if (isMultipleSelection) {
// For multiple files, we need to check if we should add or remove
const firstStatus = targetStatuses[0]; // Use first status for multi-file operations
const filesWithStatus = targetFiles.filter(file =>
this.getFileStatuses(file).includes(firstStatus)
);
operation = filesWithStatus.length > targetFiles.length / 2 ? 'remove' : 'add';
} else {
// For single file operations
if (allowMultipleStatuses) {
operation = 'toggle';
} else {
operation = 'set';
}
}
// Apply the changes
await this.modifyNoteStatus({
files: targetFiles,
statuses: targetStatuses,
operation,
showNotice
});
}
/**
* Dispatch status changed event
*/
private notifyStatusChanged(statuses: string[], file?: TFile): void {
// Dispatch the specific status change event
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: {
statuses,
file: file?.path
}
}));
}
}

View file

@ -5,93 +5,98 @@ import { PREDEFINED_TEMPLATES } from '../constants/status-templates';
* Handles dynamic styling for the plugin
*/
export class StyleService {
private dynamicStyleEl?: HTMLStyleElement;
private settings: NoteStatusSettings;
private dynamicStyleEl?: HTMLStyleElement;
private settings: NoteStatusSettings;
constructor(settings: NoteStatusSettings) {
this.settings = settings;
this.initializeDynamicStyles();
}
constructor(settings: NoteStatusSettings) {
this.settings = settings;
this.initializeDynamicStyles();
}
/**
* Updates the settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.updateDynamicStyles();
}
/**
* Updates the settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.updateDynamicStyles();
}
/**
* Creates the style element if it doesn't exist
*/
private initializeDynamicStyles(): void {
if (!this.dynamicStyleEl) {
this.dynamicStyleEl = document.createElement('style');
document.head.appendChild(this.dynamicStyleEl);
}
this.updateDynamicStyles();
}
/**
* Creates the style element if it doesn't exist
*/
private initializeDynamicStyles(): void {
if (!this.dynamicStyleEl) {
this.dynamicStyleEl = document.createElement('style');
document.head.appendChild(this.dynamicStyleEl);
}
this.updateDynamicStyles();
}
/**
* Get all status colors including those from enabled templates
*/
private getAllStatusColors(): Record<string, string> {
const colors = { ...this.settings.statusColors };
/**
* Updates the dynamic styles based on current settings
*/
private updateDynamicStyles(): void {
if (!this.dynamicStyleEl) {
this.initializeDynamicStyles();
return;
}
// Add colors from template statuses if not using custom statuses only
if (!this.settings.useCustomStatusesOnly) {
for (const templateId of this.settings.enabledTemplates) {
const template = PREDEFINED_TEMPLATES.find(t => t.id === templateId);
if (template) {
// Add template colors if they don't already exist in colors
for (const status of template.statuses) {
if (status.color && !colors[status.name]) {
colors[status.name] = status.color;
}
}
}
}
}
const allColors = this.getAllStatusColors();
const cssRules = this.generateColorCssRules(allColors);
this.dynamicStyleEl.textContent = cssRules;
}
/**
* Get all status colors including those from enabled templates
*/
private getAllStatusColors(): Record<string, string> {
const colors = { ...this.settings.statusColors };
return colors;
}
if (this.settings.useCustomStatusesOnly) return colors;
// Add colors from template statuses
for (const templateId of this.settings.enabledTemplates) {
const template = PREDEFINED_TEMPLATES.find(t => t.id === templateId);
if (!template) continue;
for (const status of template.statuses) {
if (status.color && !colors[status.name]) {
colors[status.name] = status.color;
}
}
}
/**
* Updates the dynamic styles based on current settings
*/
public updateDynamicStyles(): void {
if (!this.dynamicStyleEl) {
this.initializeDynamicStyles();
return;
}
return colors;
}
// Get all status colors
const allColors = this.getAllStatusColors();
/**
* Generate CSS rules for status colors
*/
private generateColorCssRules(colors: Record<string, string>): string {
let css = '';
for (const [status, color] of Object.entries(colors)) {
css += `
.status-${status} {
color: ${color} !important;
}
.note-status-bar .note-status-${status},
.nav-file-title .note-status-${status} {
color: ${color} !important;
}
`;
}
return css;
}
// Generate CSS for status colors
let css = '';
for (const [status, color] of Object.entries(allColors)) {
css += `
.status-${status} {
color: ${color} !important;
}
.note-status-bar .note-status-${status},
.nav-file-title .note-status-${status} {
color: ${color} !important;
}
`;
}
this.dynamicStyleEl.textContent = css;
}
/**
* Cleans up styles when plugin is unloaded
*/
public unload(): void {
if (this.dynamicStyleEl) {
this.dynamicStyleEl.remove();
this.dynamicStyleEl = undefined;
}
}
/**
* Cleans up styles when plugin is unloaded
*/
public unload(): void {
if (this.dynamicStyleEl) {
this.dynamicStyleEl.remove();
this.dynamicStyleEl = undefined;
}
}
}

View file

@ -1,347 +0,0 @@
import { App, PluginSettingTab, Setting, Notice } from 'obsidian';
import { Status } from '../models/types';
import { PREDEFINED_TEMPLATES } from '../constants/status-templates';
import NoteStatus from 'main';
/**
* Settings tab for the Note Status plugin
*/
export class NoteStatusSettingTab extends PluginSettingTab {
plugin: NoteStatus;
constructor(app: App, plugin: any) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
// Status Template section
this.displayTemplateSettings(containerEl.createDiv());
// UI section
new Setting(containerEl).setName('User interface').setHeading();
// Status bar settings
new Setting(containerEl)
.setName('Show status bar')
.setDesc('Display the status bar')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showStatusBar)
.onChange(async (value) => {
this.plugin.settings.showStatusBar = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Auto-hide status bar')
.setDesc('Hide the status bar when status is unknown')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.autoHideStatusBar)
.onChange(async (value) => {
this.plugin.settings.autoHideStatusBar = value;
await this.plugin.saveSettings();
}));
// File explorer settings
new Setting(containerEl)
.setName('Show status icons in file explorer')
.setDesc('Display status icons in the file explorer')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showStatusIconsInExplorer)
.onChange(async (value) => {
this.plugin.settings.showStatusIconsInExplorer = value;
await this.plugin.saveSettings();
}));
// NEW SETTING: Hide unknown status in explorer
new Setting(containerEl)
.setName('Hide unknown status in file explorer')
.setDesc('Hide status icons for files with unknown status in the file explorer')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.hideUnknownStatusInExplorer || false)
.onChange(async (value) => {
this.plugin.settings.hideUnknownStatusInExplorer = value;
await this.plugin.saveSettings();
// Refresh explorer icons when this setting changes
this.plugin.explorerIntegration.updateAllFileExplorerIcons();
}));
// Compact view setting
new Setting(containerEl)
.setName('Default to compact view')
.setDesc('Start the status pane in compact view by default')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.compactView || false)
.onChange(async (value) => {
this.plugin.settings.compactView = value;
await this.plugin.saveSettings();
}));
// Add option to exclude unknown status in pane view for performance
new Setting(containerEl)
.setName('Exclude unassigned notes from status pane')
.setDesc('Improves performance by excluding notes with no assigned status from the status pane. Recommended for large vaults.')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.excludeUnknownStatus || false)
.onChange(async (value) => {
this.plugin.settings.excludeUnknownStatus = value;
await this.plugin.saveSettings();
// Refresh the status pane if open
const statusPane = this.app.workspace.getLeavesOfType('status-pane')[0];
if (statusPane && statusPane.view) {
// Trigger refresh
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}
}));
new Setting(containerEl).setName('Status tag').setHeading();
// Option to use multiple statuses
new Setting(containerEl)
.setName('Enable multiple statuses')
.setDesc('Allow notes to have multiple statuses at the same time')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.useMultipleStatuses)
.onChange(async (value) => {
this.plugin.settings.useMultipleStatuses = value;
await this.plugin.saveSettings();
// Refresh UI to show multi-select or single-select options
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}));
// Status tag prefix
new Setting(containerEl)
.setName('Status tag prefix')
.setDesc('The YAML frontmatter tag name used for status (default: obsidian-note-status)')
.addText(text => text
.setValue(this.plugin.settings.tagPrefix)
.onChange(async (value) => {
// Don't allow empty tag prefix
if (!value.trim()) {
return;
}
this.plugin.settings.tagPrefix = value.trim();
await this.plugin.saveSettings();
// Add this line to trigger a full UI refresh
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}));
// Status management section
new Setting(containerEl).setName('Custom statuses').setHeading();
// Option to use only custom statuses
new Setting(containerEl)
.setName('Use only custom statuses')
.setDesc('Ignore template statuses and use only the custom statuses defined below')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.useCustomStatusesOnly || false)
.onChange(async (value) => {
this.plugin.settings.useCustomStatusesOnly = value;
await this.plugin.saveSettings();
// Refresh the UI to show/hide template statuses
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
this.display();
}));
// Custom statuses list
const statusList = containerEl.createDiv({ cls: 'custom-status-list' });
const renderStatuses = () => {
statusList.empty();
this.plugin.settings.customStatuses.forEach((status: Status, index: number) => {
const setting = new Setting(statusList)
.setName(status.name)
.setClass('status-item');
// Name field - now properly implemented
setting.addText(text => text
.setPlaceholder('Name')
.setValue(status.name)
.onChange(async (value) => {
const oldName = status.name;
status.name = value || 'unnamed';
// Update color mapping when name changes
if (oldName !== status.name) {
this.plugin.settings.statusColors[status.name] =
this.plugin.settings.statusColors[oldName];
delete this.plugin.settings.statusColors[oldName];
}
await this.plugin.saveSettings();
}));
// Icon field
setting.addText(text => text
.setPlaceholder('Icon')
.setValue(status.icon)
.onChange(async (value) => {
status.icon = value || '❓';
await this.plugin.saveSettings();
}));
// Color picker
setting.addColorPicker(colorPicker => colorPicker
.setValue(this.plugin.settings.statusColors[status.name] || '#ffffff')
.onChange(async (value) => {
this.plugin.settings.statusColors[status.name] = value;
await this.plugin.saveSettings();
}));
// Description field
setting.addText(text => text
.setPlaceholder('Description')
.setValue(status.description || '')
.onChange(async (value) => {
status.description = value;
await this.plugin.saveSettings();
}));
// Remove button
setting.addButton(button => button
.setButtonText('Remove')
.setClass('status-remove-button')
.setWarning()
.onClick(async () => {
this.plugin.settings.customStatuses.splice(index, 1);
delete this.plugin.settings.statusColors[status.name];
await this.plugin.saveSettings();
renderStatuses();
}));
});
};
renderStatuses();
// Add new status
new Setting(containerEl)
.setName('Add new status')
.setDesc('Add a custom status with a name, icon, and color')
.addButton(button => button
.setButtonText('Add Status')
.setCta()
.onClick(async () => {
const newStatus = {
name: `status${this.plugin.settings.customStatuses.length + 1}`,
icon: '⭐'
};
this.plugin.settings.customStatuses.push(newStatus);
this.plugin.settings.statusColors[newStatus.name] = '#ffffff'; // Initial white color
await this.plugin.saveSettings();
renderStatuses();
}));
}
/**
* Display template settings section
*/
private displayTemplateSettings(containerEl: HTMLElement): void {
new Setting(containerEl).setName('Status templates').setHeading();
containerEl.createEl('p', {
text: 'Enable predefined templates to quickly add common status workflows',
cls: 'setting-item-description'
});
// Create templates container
const templatesContainer = containerEl.createDiv({ cls: 'templates-container' });
// List each template with checkbox and preview
PREDEFINED_TEMPLATES.forEach(template => {
const templateEl = templatesContainer.createDiv({ cls: 'template-item' });
// Template header with checkbox and name
const headerEl = templateEl.createDiv({ cls: 'template-header' });
// Checkbox for enabling/disabling template
const isEnabled = this.plugin.settings.enabledTemplates.includes(template.id);
const checkbox = headerEl.createEl('input', {
type: 'checkbox',
cls: 'template-checkbox'
});
checkbox.checked = isEnabled;
checkbox.addEventListener('change', async () => {
if (checkbox.checked) {
// Enable template
if (!this.plugin.settings.enabledTemplates.includes(template.id)) {
this.plugin.settings.enabledTemplates.push(template.id);
}
} else {
// Disable template
this.plugin.settings.enabledTemplates = this.plugin.settings.enabledTemplates.filter(
(id: string) => id !== template.id
);
}
await this.plugin.saveSettings();
// Refresh UI
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
});
// Template name
headerEl.createEl('span', {
text: template.name,
cls: 'template-name'
});
// Template description
templateEl.createEl('div', {
text: template.description,
cls: 'template-description'
});
// Preview statuses
const statusesEl = templateEl.createDiv({ cls: 'template-statuses' });
template.statuses.forEach(status => {
const statusEl = statusesEl.createEl('div', { cls: 'template-status-chip' });
const colorDot = statusEl.createEl('span', { cls: 'status-color-dot' });
colorDot.style.setProperty('--dot-color', status.color || '#ffffff')
// Status icon and name
statusEl.createSpan({ text: `${status.icon} ${status.name}` });
});
});
// Import/Export buttons for templates
const buttonsContainer = containerEl.createDiv({ cls: 'template-buttons' });
// Import button
const importButton = buttonsContainer.createEl('button', {
text: 'Import template',
cls: 'mod-cta'
});
importButton.addEventListener('click', () => {
// Show notification about upcoming feature
new Notice('Template import functionality coming soon!');
});
// Export button
const exportButton = buttonsContainer.createEl('button', {
text: 'Export templates',
cls: ''
});
exportButton.addEventListener('click', () => {
// Show notification about upcoming feature
new Notice('Template export functionality coming soon!');
});
}
}

1349
styles.css

File diff suppressed because one or more lines are too long

70
styles/base.css Normal file
View file

@ -0,0 +1,70 @@
/*
* Base styles and variables for Note Status Plugin
*/
:root {
--status-transition-time: 0.22s;
--status-border-radius: var(--radius-s, 4px);
--status-box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, 0.15));
--status-hover-shadow: var(--shadow-m, 0 4px 12px rgba(0, 0, 0, 0.2));
--status-icon-size: 16px;
/* Animation curves */
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
}
/* Animations */
@keyframes note-status-scale-in {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
@keyframes note-status-slide-in-fade {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes note-status-fade-in-slide-down {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes note-status-pulse {
0% { transform: scale(1); }
50% { transform: scale(0.98); }
100% { transform: scale(1); }
}
/* Dark mode adjustments */
.theme-dark .note-status-popover {
box-shadow: var(--shadow-l);
}
.theme-dark .note-status-option-selecting {
box-shadow: 0 0 0 1px var(--interactive-accent);
}
/* High contrast improvements */
@media (forced-colors: active) {
.note-status-chip,
.note-status-option.is-selected {
border: 1px solid currentColor;
}
.note-status-action-button:focus-visible,
.note-status-chip:focus-visible,
.note-status-option:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
}
/* Print styles */
@media print {
.note-status-unified-dropdown,
.note-status-bar,
.note-status-pane {
display: none !important;
}
}

View file

@ -0,0 +1,250 @@
/*
* Dropdown component styles
*/
.note-status-popover {
position: absolute;
background: var(--background-primary);
border-radius: var(--radius-m);
box-shadow: var(--status-box-shadow);
z-index: calc(var(--layer-popover) + 10);
max-height: 300px;
display: flex;
flex-direction: column;
border: 1px solid var(--background-modifier-border);
min-width: 200px;
overflow: hidden;
width: auto !important;
opacity: 0;
transform: scale(0.95);
transition: opacity var(--status-transition-time) var(--ease-out),
transform var(--status-transition-time) var(--ease-out);
transform-origin: top left;
pointer-events: all !important;
}
.note-status-popover.note-status-popover-animate-in {
opacity: 1;
transform: scale(1);
}
.note-status-popover.note-status-popover-animate-out {
opacity: 0;
transform: scale(0.95);
pointer-events: none;
}
/* Search container */
.note-status-popover-search {
padding: 8px;
position: sticky;
top: 0;
background: var(--background-primary);
z-index: 3;
border-bottom: 1px solid var(--background-modifier-border);
}
/* Status options container */
.note-status-options-container {
overflow-y: auto;
overflow-x: hidden;
max-height: 250px;
padding: var(--size-4-1, 4px);
scrollbar-width: thin;
scrollbar-color: var(--background-modifier-border) transparent;
}
.note-status-options-container::-webkit-scrollbar {
width: 6px;
}
.note-status-options-container::-webkit-scrollbar-thumb {
background-color: var(--background-modifier-border);
border-radius: 3px;
}
.note-status-options-container::-webkit-scrollbar-track {
background-color: transparent;
}
/* Empty status options */
.note-status-empty-options {
padding: 16px 12px;
text-align: center;
color: var(--text-muted);
font-style: italic;
font-size: var(--font-smaller);
}
/* Status option items */
.note-status-option {
display: flex;
align-items: center;
padding: 8px 12px;
margin: 2px 4px;
border-radius: var(--radius-s);
background: var(--background-primary);
color: var(--text-normal);
cursor: pointer !important;
transition: background-color 0.15s ease;
}
.note-status-option:hover {
background: var(--background-modifier-hover);
}
.note-status-option.is-selected {
background: var(--background-secondary-alt);
font-weight: var(--font-medium);
}
.note-status-option-selecting {
background-color: var(--interactive-accent) !important;
color: var(--text-on-accent) !important;
animation: note-status-pulse 0.3s var(--ease-out);
}
.note-status-option-icon {
margin-right: 8px;
font-size: 1.1em;
}
.note-status-option-text {
flex: 1;
font-size: var(--font-smaller);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.note-status-option-check {
margin-left: auto;
color: var(--text-accent);
}
/* Unified dropdown */
.note-status-unified-dropdown {
width: 280px;
max-width: 90vw;
border-radius: var(--radius-m);
overflow: hidden;
box-shadow: var(--shadow-m);
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
z-index: 9999 !important;
pointer-events: all !important;
}
/* Animation for unified dropdown */
.note-status-popover-animate-in {
animation: note-status-dropdown-fade-in 0.22s var(--ease-out) forwards;
}
@keyframes note-status-dropdown-fade-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes note-status-dropdown-fade-out {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.95); }
}
/* Popover header */
.note-status-popover-header {
padding: 12px;
border-bottom: 1px solid var(--background-modifier-border);
background: var(--background-secondary-alt);
display: flex;
align-items: center;
position: sticky;
top: 0;
z-index: 2;
}
.note-status-popover-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: var(--font-semibold);
color: var(--text-normal);
}
.note-status-popover-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-accent);
}
.note-status-popover-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 12px;
background: var(--background-primary-alt);
max-height: 120px;
overflow-y: auto;
border-bottom: 1px solid var(--background-modifier-border);
}
/* Status chips styling */
.note-status-chip {
display: inline-flex;
align-items: center;
padding: 4px 10px;
background: var(--background-secondary);
border-radius: 16px;
box-shadow: var(--shadow-xs);
font-size: var(--font-smaller);
transition: all 0.15s var(--ease-out);
border: 1px solid var(--background-modifier-border);
max-width: 180px;
animation: note-status-scale-in 0.2s var(--ease-out);
}
.note-status-chip.clickable {
cursor: pointer;
}
.note-status-chip.clickable:hover {
background: var(--background-modifier-hover);
transform: translateY(-1px);
box-shadow: var(--status-hover-shadow);
}
.note-status-chip-removing {
transform: scale(0.8);
opacity: 0;
pointer-events: none;
transition: all 0.15s ease-out;
}
.note-status-chip-icon {
margin-right: 6px;
}
.note-status-chip-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.note-status-chip-remove {
margin-left: 6px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--background-modifier-border);
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s var(--ease-out);
}
.note-status-chip-remove:hover {
background: var(--text-accent);
color: var(--text-on-accent);
transform: scale(1.1);
}

View file

@ -0,0 +1,58 @@
/*
* Explorer integration styles
*/
.note-status-toolbar-badge-container {
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.note-status-toolbar-icon {
color: var(--text-normal) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.note-status-count-badge {
position: absolute;
top: -5px;
right: -8px;
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-radius: 10px;
font-size: 10px;
min-width: 14px;
height: 14px;
padding: 0 3px;
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-bold);
}
/* Explorer icons container */
.note-status-icon-container {
display: inline-flex;
margin-left: var(--size-4-1, 4px);
gap: 2px;
align-items: center;
flex-shrink: 0;
}
/* Individual status icons in explorer */
.note-status-icon {
font-size: var(--font-ui-smaller);
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--status-icon-size);
height: var(--status-icon-size);
position: relative;
z-index: 500; /* Higher than most elements */
}
.nav-file-title .note-status-icon::after {
bottom: 150%; /* Position above in file explorer */
}

View file

@ -0,0 +1,62 @@
/*
* Settings styles
*/
/* Template styling */
.status-color-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--dot-color, #ffffff);
margin-right: 4px;
}
.template-buttons {
margin-top: 15px;
display: flex;
gap: 10px;
}
/* Template selection styling */
.template-item {
border: 1px solid var(--background-modifier-border);
border-radius: var(--status-border-radius);
margin-bottom: 10px;
padding: 10px;
background: var(--background-primary-alt);
}
.template-header {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.template-checkbox {
margin-right: 10px;
}
.template-name {
font-weight: bold;
}
.template-description {
font-size: 0.9em;
color: var(--text-muted);
margin-bottom: 8px;
}
.template-statuses {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.template-status-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 12px;
background: var(--background-secondary);
font-size: 0.85em;
}

View file

@ -0,0 +1,64 @@
/*
* Status bar styles
*/
.note-status-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: var(--radius-s);
transition: all 0.2s var(--ease-out);
cursor: pointer;
height: 22px;
}
.note-status-bar:hover {
background-color: var(--background-modifier-hover);
}
.note-status-badges {
display: flex;
align-items: center;
gap: 4px;
}
.note-status-badge {
display: flex;
align-items: center;
padding: 2px 6px;
border-radius: 10px;
background: var(--background-secondary-alt);
font-size: var(--font-ui-smaller);
max-width: 100px;
margin-right: 2px;
}
.note-status-badge-icon {
margin-right: 2px;
}
.note-status-badge-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Status bar positioning and visibility */
.status-bar .note-status-icon::after {
bottom: auto;
top: -30px;
}
/* Auto-hide behavior */
.note-status-bar.hidden {
display: none;
}
.note-status-bar.auto-hide {
opacity: 0.5;
transition: opacity 0.2s var(--ease-out);
}
.note-status-bar.visible {
opacity: 1;
}

View file

@ -0,0 +1,313 @@
/*
* Status Pane Styles
*/
.note-status-pane {
padding: var(--size-4-2, 8px);
background: var(--background-secondary);
color: var(--text-normal);
font-family: var(--font-interface);
overflow-y: auto;
height: 100%;
display: flex;
flex-direction: column;
}
/* Header elements */
.note-status-header {
position: sticky;
top: 0;
z-index: 10;
background: var(--background-secondary);
padding-bottom: var(--size-4-2, 8px);
border-bottom: 1px solid var(--background-modifier-border);
margin-bottom: var(--size-4-2, 8px);
display: flex;
flex-direction: column;
gap: var(--size-4-2, 8px);
}
/* Search input styles */
.note-status-search {
width: 100%;
position: relative;
}
.search-input-wrapper,
.note-status-search-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-input-icon,
.note-status-search-icon {
position: absolute;
left: var(--size-4-1, 4px);
color: var(--text-muted);
display: flex;
align-items: center;
pointer-events: none;
padding: 4px;
}
.note-status-search-input,
.note-status-popover-search-input {
width: 100%;
padding: var(--input-padding);
padding-left: calc(var(--size-4-1, 4px) * 3 + 18px);
border: var(--input-border-width) solid var(--background-modifier-border);
border-radius: var(--input-radius);
background: var(--background-primary);
color: var(--text-normal);
outline: none;
transition: all var(--status-transition-time) var(--ease-out);
}
.note-status-search-input:focus,
.note-status-popover-search-input:focus {
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px var(--interactive-accent-hover);
}
.search-input-clear-button,
.note-status-search-clear-button {
opacity: 0;
position: absolute;
right: var(--size-4-1, 4px);
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
transition: opacity var(--status-transition-time) var(--ease-out);
padding: 4px;
border-radius: 50%;
}
.search-input-clear-button.is-visible,
.note-status-search-clear-button.is-visible {
opacity: 1;
}
.search-input-clear-button:hover,
.note-status-search-clear-button:hover {
color: var(--text-normal);
background-color: var(--background-modifier-hover);
}
/* Actions toolbar */
.status-pane-actions-container {
display: flex;
gap: var(--size-4-2, 8px);
justify-content: flex-end;
margin-top: var(--size-4-1, 4px);
}
.note-status-view-toggle,
.note-status-actions-refresh {
padding: var(--size-4-1, 4px) var(--size-4-2, 8px);
border: var(--input-border-width) solid var(--background-modifier-border);
border-radius: var(--button-radius);
background: var(--background-primary);
color: var(--text-normal);
cursor: pointer;
transition: all var(--status-transition-time) var(--ease-out);
display: flex;
align-items: center;
justify-content: center;
}
.note-status-view-toggle:hover,
.note-status-actions-refresh:hover {
background: var(--background-modifier-hover);
box-shadow: var(--shadow-s);
}
.note-status-view-toggle:active,
.note-status-actions-refresh:active {
transform: translateY(1px);
box-shadow: none;
}
/* Status Groups Container */
.note-status-groups-container {
flex: 1;
overflow-y: auto;
padding-right: 2px; /* Prevents scrollbar from hugging the edge */
}
/* Status Group Styling */
.note-status-group {
margin-bottom: var(--size-4-3, 12px);
border-radius: var(--radius-s);
background: var(--background-primary-alt);
box-shadow: var(--shadow-xs);
overflow: hidden;
animation: note-status-fade-in-slide-down 0.3s var(--ease-out);
}
.note-status-group .nav-folder-title {
cursor: pointer;
padding: var(--size-4-1, 4px) var(--size-4-2, 8px);
background: var(--background-secondary-alt);
transition: background var(--status-transition-time) var(--ease-out);
}
.note-status-group .nav-folder-title:hover {
background: var(--background-modifier-hover);
}
.note-status-group .nav-folder-title-content {
font-weight: var(--font-semibold);
display: flex;
align-items: center;
gap: var(--size-4-1, 4px);
}
/* Collapse indicators */
.note-status-collapse-indicator {
margin-right: var(--size-4-2, 8px);
display: flex;
align-items: center;
color: var(--text-muted);
transition: transform var(--status-transition-time) var(--ease-in-out);
opacity: 1;
}
.note-status-is-collapsed .note-status-collapse-indicator {
transform: rotate(-90deg);
}
/* File items */
.note-status-group .nav-folder-children {
padding: var(--size-4-1, 4px);
background: var(--background-primary);
transition: height var(--status-transition-time) var(--ease-out);
}
.note-status-group.nav-folder.note-status-is-collapsed .nav-folder-children {
display: none;
}
.nav-file {
border-radius: var(--radius-s);
transition: background var(--status-transition-time) var(--ease-out);
margin-bottom: 2px;
position: relative;
}
.nav-file:hover {
background: var(--background-modifier-hover);
}
.nav-file-title {
display: flex;
align-items: center;
padding: var(--size-4-1, 4px) var(--size-4-2, 8px);
flex-wrap: nowrap;
}
.nav-file-icon {
color: var(--text-muted);
margin-right: var(--size-4-2, 8px);
display: flex;
align-items: center;
flex-shrink: 0;
}
.nav-file-title-content {
flex: 1;
min-width: 0; /* Allows text to shrink properly */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Compact view */
.note-status-compact-view .nav-file-title {
padding: 2px var(--size-4-2, 8px);
font-size: var(--font-smaller);
}
.note-status-compact-view .nav-folder-children {
padding: 0;
}
.note-status-compact-view .nav-file {
margin-bottom: 0;
border-radius: 0;
border-bottom: 1px solid var(--background-modifier-border);
}
.note-status-compact-view .nav-file:last-child {
border-bottom: none;
}
/* Empty message and show unassigned button */
.note-status-empty-message {
margin-bottom: 12px;
text-align: center;
color: var(--text-muted);
font-style: italic;
}
.note-status-button-container {
display: flex;
justify-content: center;
margin-top: 16px;
}
.note-status-show-unassigned-button {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
padding: 8px 16px;
border-radius: var(--radius-s, 4px);
border: none;
cursor: pointer;
font-size: var(--font-ui-small);
transition: all 0.2s var(--ease-out);
box-shadow: var(--shadow-s);
}
.note-status-show-unassigned-button:hover {
background-color: var(--interactive-accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-m);
}
.note-status-show-unassigned-button:active {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
/*
* Pagination styles
*/
.note-status-pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-top: 1px solid var(--background-modifier-border);
margin-top: 8px;
}
.note-status-pagination-button {
padding: 4px 8px;
border-radius: var(--radius-s);
background: var(--background-secondary);
border: var(--input-border-width) solid var(--background-modifier-border);
color: var(--text-normal);
cursor: pointer;
transition: all 0.15s var(--ease-out);
font-size: var(--font-smaller);
}
.note-status-pagination-button:hover {
background: var(--background-modifier-hover);
box-shadow: var(--shadow-s);
}
.note-status-pagination-info {
font-size: var(--font-smaller);
color: var(--text-muted);
}

14
styles/index.css Normal file
View file

@ -0,0 +1,14 @@
/*
* Note Status Plugin for Obsidian
* Main CSS file importing all components
*
* Author: Aleix Soler
*/
@import 'base.css';
@import 'utils.css';
@import 'components/status-bar.css';
@import 'components/status-pane.css';
@import 'components/dropdown.css';
@import 'components/explorer.css';
@import 'components/settings.css';

106
styles/utils.css Normal file
View file

@ -0,0 +1,106 @@
/*
* Utility classes for Note Status Plugin
*/
.note-status-empty-indicator {
color: var(--text-muted);
font-style: italic;
padding: var(--size-4-1, 4px);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-accent);
font-size: 1.2em;
}
/* Action buttons in modal */
.note-status-action-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-s);
background: transparent;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s ease;
border: none;
padding: 0;
}
.note-status-action-button:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
.note-status-action-button.note-status-button-active {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
transform: scale(0.95);
}
/* Loading indicator */
.note-status-loading {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
color: var(--text-muted);
font-style: italic;
}
.note-status-loading span {
position: relative;
padding-left: 24px;
}
.note-status-loading span:before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 16px;
height: 16px;
margin-top: -8px;
border: 2px solid var(--background-modifier-border);
border-top-color: var(--text-accent);
border-radius: 50%;
animation: note-status-loading-spinner 0.8s linear infinite;
}
@keyframes note-status-loading-spinner {
to { transform: rotate(360deg); }
}
/* Positioning classes for dropdowns */
.note-status-dummy-target {
position: fixed;
z-index: 1000;
width: 0;
height: 0;
left: var(--pos-x-px, 0);
top: var(--pos-y-px, 0);
pointer-events: none;
}
.note-status-popover-fixed {
position: fixed;
z-index: 999;
--pos-x: 0;
--pos-y: 0;
--max-height: 300px;
left: var(--pos-x-px, 0);
top: var(--pos-y-px, 0);
max-height: var(--max-height-px, 300px);
}
/* Position adjustment classes */
.note-status-popover-right {
left: auto !important;
right: var(--right-offset-px, 10px);
}
.note-status-popover-bottom {
top: auto !important;
bottom: var(--bottom-offset-px, 10px);
}

View file

@ -1,145 +0,0 @@
import { setTooltip } from 'obsidian';
import { NoteStatusSettings } from '../../models/types';
import { StatusService } from '../../services/status-service';
/**
* Handles the status bar functionality
*/
export class StatusBar {
private statusBarEl: HTMLElement;
private settings: NoteStatusSettings;
private statusService: StatusService;
private currentStatuses: string[] = ['unknown'];
constructor(statusBarEl: HTMLElement, settings: NoteStatusSettings, statusService: StatusService) {
this.statusBarEl = statusBarEl;
this.settings = settings;
this.statusService = statusService;
// Add initial class
this.statusBarEl.addClass('note-status-bar');
// Add right-click handler for force refresh
this.statusBarEl.addEventListener('contextmenu', (e) => {
e.preventDefault();
window.dispatchEvent(new CustomEvent('note-status:force-refresh'));
});
// Initial render
this.update(['unknown']);
}
/**
* Update the status bar with new statuses
*/
public update(statuses: string[]): void {
this.currentStatuses = statuses;
this.render();
}
/**
* Render the status bar based on current settings and statuses
*/
public render(): void {
this.statusBarEl.empty();
this.statusBarEl.removeClass('left', 'hidden', 'auto-hide', 'visible');
this.statusBarEl.addClass('note-status-bar');
if (!this.settings.showStatusBar) {
this.statusBarEl.addClass('hidden');
return;
}
// Handle single vs. multiple status display
if (this.currentStatuses.length === 1 || !this.settings.useMultipleStatuses) {
// Display single status
const primaryStatus = this.currentStatuses[0];
// Get status object to get description
const statusObj = this.statusService.getAllStatuses().find(s => s.name === primaryStatus);
const tooltipValue = statusObj?.description ? `${primaryStatus} - ${statusObj.description}` : primaryStatus;
// Create status text
const statusText = this.statusBarEl.createEl('span', {
text: `Status: ${primaryStatus}`,
cls: `note-status-${primaryStatus}`
});
// Add tooltip
setTooltip(statusText, tooltipValue);
// Create status icon
const statusIcon = this.statusBarEl.createEl('span', {
text: this.statusService.getStatusIcon(primaryStatus),
cls: `note-status-icon status-${primaryStatus}`
});
// Add tooltip to icon too
setTooltip(statusIcon, tooltipValue);
} else {
// Display multiple statuses
// Create status text
this.statusBarEl.createEl('span', {
text: `Statuses: `,
cls: 'note-status-label'
});
// Create container for status badges
const badgesContainer = this.statusBarEl.createEl('span', {
cls: 'note-status-badges'
});
// Add status badges
this.currentStatuses.forEach(status => {
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}` : status;
const badge = badgesContainer.createEl('span', {
cls: `note-status-badge status-${status}`
});
// Add tooltip
setTooltip(badge, tooltipValue);
badge.createEl('span', {
text: this.statusService.getStatusIcon(status),
cls: 'note-status-badge-icon'
});
badge.createEl('span', {
text: status,
cls: 'note-status-badge-text'
});
});
}
// Handle auto-hide behavior
const onlyUnknown = this.currentStatuses.length === 1 && this.currentStatuses[0] === 'unknown';
if (this.settings.autoHideStatusBar && onlyUnknown) {
this.statusBarEl.addClass('auto-hide');
setTimeout(() => {
if (onlyUnknown && this.settings.showStatusBar) {
this.statusBarEl.addClass('hidden');
}
}, 500);
} else {
this.statusBarEl.addClass('visible');
}
}
/**
* Update settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.render();
}
/**
* Clean up when plugin is unloaded
*/
public unload(): void {
// Clean up any event listeners or DOM elements
this.statusBarEl.empty();
}
}

View file

@ -1,642 +0,0 @@
import { App, setIcon, TFile, setTooltip } 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: App;
private statusService: StatusService;
private settings: NoteStatusSettings;
private dropdownElement: HTMLElement | null = null;
private currentStatuses: string[] = ['unknown'];
private onStatusChange: (statuses: string[]) => void;
private animationDuration = 220;
private clickOutsideHandler: (e: MouseEvent) => void;
private targetFile: TFile | null = null;
public isOpen = false;
constructor(app: App, 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();
// Add small delay to ensure dropdown is fully closed before potentially reopening
// This prevents state conflicts when clicking in rapid succession
setTimeout(() => {
// Check if we're still in the closing state to prevent reopening if user clicked elsewhere
if (!this.isOpen && !this.dropdownElement) {
this.open(targetEl, position);
}
}, 50);
} else {
this.open(targetEl, position);
}
}
/**
* Open the dropdown
*/
public open(targetEl: HTMLElement, position?: { x: number, y: number }): void {
// Make sure any existing dropdown is fully closed
if (this.isOpen || this.dropdownElement) {
this.close();
// Add small delay before opening new dropdown to ensure proper cleanup
setTimeout(() => {
this.actuallyOpen(targetEl, position);
}, 10);
return;
}
this.actuallyOpen(targetEl, position);
}
private actuallyOpen(targetEl: HTMLElement, position?: { x: number, y: number }): void {
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);
// Set isOpen to false immediately to prevent multiple open/close conflicts
this.isOpen = false;
// Remove after animation completes
setTimeout(() => {
if (this.dropdownElement) {
this.dropdownElement.remove();
this.dropdownElement = null;
}
}, 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 regardless of the number of statuses
// This allows removal even when there's only one status
this.addRemoveButton(chipEl, status);
}
/**
* Add a remove button to a status chip
*/
private addRemoveButton(chipEl: HTMLElement, status: string): void {
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}`: status;
// Add tooltip to the chip element
setTooltip(chipEl, tooltipValue);
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');
// Wait for animation to complete before actually removing
setTimeout(async () => {
if (this.targetFile) {
// Remove status from single file
await this.removeStatus(status);
} else {
// This is a batch operation - remove from all files
// Call the onStatusChange callback with the status to be removed
this.onStatusChange([status]);
}
}, 150);
});
}
/**
* Remove a status from the target file
*/
private async removeStatus(status: string): Promise<void> {
if (!this.targetFile) return;
// Remove this status
await this.statusService.removeNoteStatus(status, this.targetFile);
// Get updated statuses
const updatedStatuses = this.statusService.getFileStatuses(this.targetFile);
// Update current statuses
this.currentStatuses = updatedStatuses;
// Refresh chips
this.refreshDropdownContent();
// Call status change callback
this.onStatusChange(updatedStatuses);
}
/**
* Create the search filter input
*/
private createSearchFilter(): HTMLInputElement {
if (!this.dropdownElement) {
throw new Error("Dropdown element not initialized");
}
const searchContainer = this.dropdownElement.createDiv({ cls: 'note-status-popover-search' });
const searchInput = searchContainer.createEl('input', {
type: 'text',
placeholder: 'Filter statuses...',
cls: 'note-status-popover-search-input'
});
return searchInput;
}
/**
* Populate status options with optional filtering
*/
private populateStatusOptions(
container: HTMLElement,
statuses: Status[],
filter = ''
): void {
container.empty();
const filteredStatuses = this.filterStatuses(statuses, filter);
if (filteredStatuses.length === 0) {
this.createNoMatchingStatusesMessage(container, filter);
return;
}
filteredStatuses.forEach(status => {
this.createStatusOption(container, status);
});
}
/**
* Filter statuses based on search term
*/
private filterStatuses(statuses: Status[], filter: string): Status[] {
if (!filter) return statuses;
return statuses.filter(status =>
status.name.toLowerCase().includes(filter.toLowerCase()) ||
status.icon.includes(filter)
);
}
/**
* Create a message for no matching statuses
*/
private createNoMatchingStatusesMessage(container: HTMLElement, filter: string): void {
container.createDiv({
cls: 'note-status-empty-options',
text: filter ? `No statuses match "${filter}"` : 'No statuses found'
});
}
/**
* Create a single status option element
*/
private createStatusOption(container: HTMLElement, status: Status): void {
const isSelected = this.currentStatuses.includes(status.name);
const optionEl = container.createDiv({
cls: `note-status-option ${isSelected ? 'is-selected' : ''} status-${status.name}`
});
// Status icon
optionEl.createSpan({
text: status.icon,
cls: 'note-status-option-icon'
});
// Status name
optionEl.createSpan({
text: status.name,
cls: 'note-status-option-text'
});
// Add tooltip with description if available
if (status.description) {
setTooltip(optionEl, `${status.name} - ${status.description}`);
}
// Check icon for selected status
if (isSelected) {
const checkIcon = optionEl.createDiv({ cls: 'note-status-option-check' });
setIcon(checkIcon, 'check');
}
// Add click handler
optionEl.addEventListener('click', () => this.handleStatusOptionClick(optionEl, status));
}
/**
* Handle click on a status option
*/
private handleStatusOptionClick(optionEl: HTMLElement, status: Status): void {
try {
// Add selection animation
optionEl.addClass('note-status-option-selecting');
// Apply status changes after brief delay for animation
setTimeout(async () => {
if (this.targetFile) {
await this.handleStatusChangeForTargetFile(status);
} else {
// This is for batch operations or when no specific file is targeted
this.onStatusChange([status.name]);
// Usually we want to close after selection in this case
if (!this.settings.useMultipleStatuses) {
this.close();
}
}
}, 150);
} catch (error) {
console.error('Error updating status:', error);
}
}
/**
* Handle status change for a specific target file
*/
private async handleStatusChangeForTargetFile(status: Status): Promise<void> {
if (!this.targetFile) return;
if (this.settings.useMultipleStatuses) {
await this.statusService.toggleNoteStatus(status.name, this.targetFile);
} else {
await this.statusService.updateNoteStatuses([status.name], this.targetFile);
// Close dropdown in single status mode
this.close();
}
// Get fresh status from file
const freshStatuses = this.statusService.getFileStatuses(this.targetFile);
// Update current statuses
this.currentStatuses = [...freshStatuses];
// Call status change callback
this.onStatusChange(freshStatuses);
}
/**
* Position the dropdown at specific coordinates
*/
private positionAt(x: number, y: number): void {
if (!this.dropdownElement) return;
this.dropdownElement.addClass('note-status-popover-fixed');
this.dropdownElement.style.setProperty('--pos-x-px', `${x}px`);
this.dropdownElement.style.setProperty('--pos-y-px', `${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.addClass('note-status-popover-right');
this.dropdownElement.style.setProperty('--right-offset-px', '10px');
} else {
this.dropdownElement.removeClass('note-status-popover-right');
}
if (rect.bottom > window.innerHeight) {
this.dropdownElement.addClass('note-status-popover-bottom');
this.dropdownElement.style.setProperty('--bottom-offset-px', '10px');
} else {
this.dropdownElement.removeClass('note-status-popover-bottom');
}
// Set max height based on viewport
const maxHeight = window.innerHeight - rect.top - 20;
this.dropdownElement.style.setProperty('--max-height-px', `${maxHeight}px`);
}
/**
* Position the dropdown relative to a target element
*/
private positionRelativeTo(targetEl: HTMLElement): void {
if (!this.dropdownElement) return;
// Apply positioning class
this.dropdownElement.addClass('note-status-popover-fixed');
// Get target element's position
const targetRect = targetEl.getBoundingClientRect();
// Position below the element - using CSS custom properties
this.dropdownElement.style.setProperty('--pos-y-px', `${targetRect.bottom + 5}px`);
// Align to left edge of target by default
this.dropdownElement.style.setProperty('--pos-x-px', `${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.addClass('note-status-popover-right');
this.dropdownElement.style.setProperty('--right-offset-px', `${window.innerWidth - targetRect.right}px`);
} else {
this.dropdownElement.removeClass('note-status-popover-right');
}
if (rect.bottom > window.innerHeight) {
// Position above the element instead
this.dropdownElement.addClass('note-status-popover-bottom');
this.dropdownElement.style.setProperty('--bottom-offset-px', `${window.innerHeight - targetRect.top + 5}px`);
} else {
this.dropdownElement.removeClass('note-status-popover-bottom');
}
// Set max height based on viewport
const maxHeight = window.innerHeight - rect.top - 20;
this.dropdownElement.style.setProperty('--max-height-px', `${maxHeight}px`);
}
/**
* Handle click outside the dropdown
*/
private handleClickOutside(e: MouseEvent): void {
if (this.dropdownElement && !this.dropdownElement.contains(e.target as Node)) {
this.close();
}
}
/**
* Handle escape key to close dropdown
*/
private handleEscapeKey(e: KeyboardEvent): void {
if (e.key === 'Escape') {
this.close();
}
}
/**
* Dispose of resources
*/
public dispose(): void {
this.close();
}
}

View file

@ -1,582 +0,0 @@
import { MarkdownView, Editor, Notice, TFile } 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 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();
}
/**
* 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;
}
// 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('.workspace-leaf .view-header .view-actions')
}
/**
* Create the toolbar button element
*/
private createToolbarButton(toolbarContainer: HTMLElement): void {
// Create the button element
this.toolbarButton = document.createElement('button');
this.toolbarButton.addClass('note-status-toolbar-button', 'clickable-icon', 'view-action');
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();
});
// Insert at a more reliable position - just prepend to the container
try {
toolbarContainer.prepend(this.toolbarButton);
} catch (error) {
console.error('Note Status: Error inserting toolbar button', error);
// Fallback - just append it
toolbarContainer.appendChild(this.toolbarButton);
}
}
/**
* 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) {
// Add status badge with proper icon
this.addStatusBadge(badgeContainer);
} else {
// Add default status icon when no valid status
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');
// Use the statusService to get the proper icon for 'unknown' status
iconSpan.textContent = this.statusService.getStatusIcon('unknown');
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 with new status
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 {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
this.openStatusDropdown({
target: this.toolbarButton,
files: [activeFile]
});
}
/**
* Show status dropdown in context menu
*/
public showInContextMenu(editor: Editor, view: MarkdownView): void {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
// Pass context to indicate this is from editor context menu
this.openStatusDropdown({
editor: editor,
view: view,
files: [activeFile],
onStatusChange: async (statuses) => {
if (statuses.length > 0) {
// Use a direct file operation, not batch update
if (this.settings.useMultipleStatuses) {
await this.statusService.toggleNoteStatus(statuses[0], activeFile);
} else {
await this.statusService.updateNoteStatuses(statuses, activeFile);
}
// Trigger UI updates
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses, file: activeFile.path }
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}
}
});
}
/**
* Get position from cursor or fallback positions
*/
private getCursorPosition(editor: Editor, view: MarkdownView): { x: number, y: number } {
try {
// Get cursor position in the document
const cursor = editor.getCursor('head');
// Try to find the DOM representation of the cursor position
const lineElement = view.contentEl.querySelector(`.cm-line:nth-child(${cursor.line + 1})`);
if (lineElement) {
const rect = lineElement.getBoundingClientRect();
// Position near the current line with some offset
return {
x: rect.left + 20,
y: rect.bottom + 5
};
}
// 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.addClass('note-status-dummy-target');
dummyTarget.style.setProperty('--pos-x-px', `${position.x}px`);
dummyTarget.style.setProperty('--pos-y-px', `${position.y}px`);
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.toolbarButton) {
this.toolbarButton.remove();
this.toolbarButton = undefined;
}
}
/**
* Universal function to open the status dropdown in any context
* @param options Configuration options for opening the dropdown
*/
public openStatusDropdown(options: {
target?: HTMLElement;
position?: { x: number, y: number };
files?: TFile[];
editor?: Editor;
view?: MarkdownView;
mode?: 'replace' | 'add';
onStatusChange?: (statuses: string[]) => void;
}): void {
// IMPORTANT: Force reset the dropdown component's state
// Add this at the beginning of the method
if (this.dropdownComponent.isOpen) {
this.dropdownComponent.close();
// Give it a moment to clean up before proceeding
setTimeout(() => {
this._openStatusDropdown(options);
}, 50);
return;
}
this._openStatusDropdown(options);
}
/**
* Find common statuses across multiple files
*/
private findCommonStatuses(files: TFile[]): string[] {
if (files.length === 0) return ['unknown'];
// Get statuses for first file
const firstFileStatuses = this.statusService.getFileStatuses(files[0]);
// Filter to only include statuses that exist on all files
return firstFileStatuses.filter(status => {
// Skip unknown status
if (status === 'unknown') return false;
// Check if status exists on all files
return files.every(file =>
this.statusService.getFileStatuses(file).includes(status)
);
});
}
/**
* Toggle a status across multiple files
*/
private async toggleStatusForFiles(files: TFile[], status: string): Promise<void> {
// Count how many files have this status
const filesWithStatus = files.filter(file =>
this.statusService.getFileStatuses(file).includes(status)
);
// If more than half have the status, remove it; otherwise, add it
const shouldRemove = filesWithStatus.length > files.length / 2;
for (const file of files) {
if (shouldRemove) {
await this.statusService.removeNoteStatus(status, file);
} else {
await this.statusService.addNoteStatus(status, file);
}
}
}
private _openStatusDropdown(options: {
target?: HTMLElement;
position?: { x: number, y: number };
files?: TFile[];
editor?: Editor;
view?: MarkdownView;
mode?: 'replace' | 'add';
onStatusChange?: (statuses: string[]) => void;
}): void {
// If no files provided, use active file
const files = options.files || [this.app.workspace.getActiveFile()].filter(Boolean);
if (!files.length) {
new Notice('No files selected');
return;
}
// IMPORTANT: Always reset state at the beginning of each operation
// This ensures no lingering callbacks or files from previous operations
this.dropdownComponent.setTargetFile(null);
this.dropdownComponent.setOnStatusChange(() => {});
// Determine if we're handling single or multiple files
const isSingleFile = files.length === 1;
const targetFile = isSingleFile ? files[0] : null;
// Set target file (if single) or null (if multiple)
this.dropdownComponent.setTargetFile(targetFile);
// Get current statuses appropriately
let currentStatuses: string[];
if (targetFile) {
// For single file, get its current statuses
currentStatuses = this.statusService.getFileStatuses(targetFile);
} else if (files.length > 1) {
// For multiple files, find common statuses to display
currentStatuses = this.findCommonStatuses(files);
} else {
currentStatuses = ['unknown'];
}
// Update dropdown with current statuses
this.dropdownComponent.updateStatuses(currentStatuses);
// Set custom callback for status changes if provided
if (options.onStatusChange) {
// Use the provided callback directly
this.dropdownComponent.setOnStatusChange(options.onStatusChange);
} else if (!isSingleFile) {
// Create a local copy of files to avoid closure issues
const filesForBatch = [...files];
// Set batch operation callback for multiple files
// Use toggle behavior for batch operations like single files
this.dropdownComponent.setOnStatusChange(async (statuses) => {
if (statuses.length > 0) {
// Get the last selected status for toggling
const toggledStatus = statuses[statuses.length - 1];
// Toggle this status across all files
await this.toggleStatusForFiles(filesForBatch, toggledStatus);
// Dispatch events for UI update
window.dispatchEvent(new CustomEvent('note-status:batch-update-complete', {
detail: {
status: toggledStatus,
fileCount: filesForBatch.length
}
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
}
});
} else {
// Default callback for standard operations
this.dropdownComponent.setOnStatusChange((statuses) => {
// Just emit events for UI updates
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { statuses }
}));
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
});
}
// For dropdown from editor
if (options.editor && options.view) {
const position = this.getCursorPosition(options.editor, options.view);
const dummyTarget = this.createDummyTarget(position);
this.dropdownComponent.open(dummyTarget, position);
// Clean up dummy target
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
}, 100);
return;
}
// For dropdown from toolbar button
if (options.target) {
if (options.position) {
this.dropdownComponent.open(options.target, options.position);
} else {
const rect = options.target.getBoundingClientRect();
const position = {
x: rect.left,
y: rect.bottom + 5
};
this.dropdownComponent.open(options.target, position);
}
return;
}
// For direct position (context menus)
if (options.position) {
const dummyTarget = document.createElement('div');
dummyTarget.addClass('note-status-dummy-target');
dummyTarget.style.setProperty('--pos-x-px', `${options.position.x}px`);
dummyTarget.style.setProperty('--pos-y-px', `${options.position.y}px`);
document.body.appendChild(dummyTarget);
this.dropdownComponent.open(dummyTarget, options.position);
// Clean up dummy target
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
}, 100);
return;
}
// Fallback to center position
const center = {
x: window.innerWidth / 2,
y: window.innerHeight / 3
};
const fallbackTarget = document.createElement('div');
fallbackTarget.addClass('note-status-dummy-target');
fallbackTarget.style.setProperty('--pos-x-px', `${center.x}px`);
fallbackTarget.style.setProperty('--pos-y-px', `${center.y}px`);
document.body.appendChild(fallbackTarget);
this.dropdownComponent.open(fallbackTarget, center);
// Clean up fallback target
setTimeout(() => {
if (fallbackTarget.parentNode) {
fallbackTarget.parentNode.removeChild(fallbackTarget);
}
}, 100);
}
/**
* Adds the toolbar button to the active leaf
*/
public addToolbarButtonToActiveLeaf(): void {
const activeLeaf = this.app.workspace.activeLeaf;
if (!activeLeaf || !activeLeaf.view || !(activeLeaf.view instanceof MarkdownView)) {
return;
}
// Get the toolbar container
const containerEl = activeLeaf.view.containerEl;
const toolbarContainer = containerEl.querySelector('.view-header .view-actions');
if (!toolbarContainer) {
return;
}
// Check if button already exists in this toolbar
const existingButton = toolbarContainer.querySelector('.note-status-toolbar-button');
if (existingButton) {
this.toolbarButton = existingButton as HTMLElement;
this.updateToolbarButton(); // Update existing button
return;
}
// Create new button
this.toolbarButton = document.createElement('button');
this.toolbarButton.addClass('note-status-toolbar-button', 'clickable-icon', 'view-action');
this.toolbarButton.setAttribute('aria-label', 'Note status');
// Update the button state
this.updateToolbarButton();
// Add click handler
this.toolbarButton.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
this.toggleStatusPopover();
});
// Add to toolbar at the beginning (left side) instead of the end
if (toolbarContainer.firstChild) {
toolbarContainer.insertBefore(this.toolbarButton, toolbarContainer.firstChild);
} else {
toolbarContainer.appendChild(this.toolbarButton);
}
}
}

View file

@ -1,452 +0,0 @@
import { TFile, WorkspaceLeaf, View, Menu, Notice, setIcon } from 'obsidian';
import { NoteStatusSettings } from '../../models/types';
import { StatusService } from '../../services/status-service';
import NoteStatus from 'main';
/**
* Status Pane View for managing note statuses
*/
export class StatusPaneView extends View {
plugin: NoteStatus;
searchInput: HTMLInputElement | null = null;
private settings: NoteStatusSettings;
private statusService: StatusService;
private paginationState: {
itemsPerPage: number;
currentPage: Record<string, number>;
};
constructor(leaf: WorkspaceLeaf, plugin: NoteStatus) {
super(leaf);
this.plugin = plugin;
this.settings = plugin.settings;
this.statusService = plugin.statusService;
// Initialize pagination
this.paginationState = {
itemsPerPage: 100, // Show 100 items per page by default
currentPage: {} // Track current page for each status
};
}
getViewType(): string {
return 'status-pane';
}
getDisplayText(): string {
return 'Status pane';
}
getIcon(): string {
return 'status-pane';
}
async onOpen(): Promise<void> {
await this.setupPane();
}
async setupPane(): Promise<void> {
const { containerEl } = this;
containerEl.empty();
containerEl.addClass('note-status-pane', 'nav-files-container');
// Add a header container for better layout
const headerContainer = containerEl.createDiv({ cls: 'note-status-header' });
// Search container with search input
this.createSearchInput(headerContainer);
// Actions toolbar (view toggle, refresh)
this.createActionToolbar(headerContainer);
// Set initial compact view state
containerEl.toggleClass('compact-view', this.settings.compactView);
// Add a container for the groups
const groupsContainer = containerEl.createDiv({ cls: 'note-status-groups-container' });
// Add loading indicator
const loadingIndicator = groupsContainer.createDiv({ cls: 'note-status-loading' });
loadingIndicator.innerHTML = '<span>Loading notes...</span>';
// Use non-blocking render
setTimeout(async () => {
await this.renderGroups('');
loadingIndicator.remove();
}, 10);
}
private createSearchInput(container: HTMLElement): void {
const searchContainer = container.createDiv({ cls: 'note-status-search search-input-container' });
const searchWrapper = searchContainer.createDiv({ cls: 'search-input-wrapper' });
// Search icon
const searchIcon = searchWrapper.createEl('span', { cls: 'search-input-icon' });
setIcon(searchIcon, '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' });
setIcon(clearSearchBtn, 'x');
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'
});
setIcon(viewToggleButton, this.settings.compactView ? 'layout' : 'table');
viewToggleButton.addEventListener('click', async () => {
this.settings.compactView = !this.settings.compactView;
viewToggleButton.title = this.settings.compactView ? 'Switch to Standard View' : 'Switch to Compact View';
viewToggleButton.empty();
setIcon(viewToggleButton, this.settings.compactView ? 'layout' : 'table');
// 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'
});
setIcon(refreshButton, 'refresh-cw');
refreshButton.addEventListener('click', async () => {
await this.renderGroups(this.searchInput?.value.toLowerCase() || '');
new Notice('Status pane refreshed');
});
}
async renderGroups(searchQuery = ''): Promise<void> {
const { containerEl } = this;
const groupsContainer = containerEl.querySelector('.note-status-groups-container');
if (!groupsContainer) return;
// Show loading indicator for non-empty search queries
if (searchQuery) {
groupsContainer.empty();
const loadingIndicator = groupsContainer.createDiv({ cls: 'note-status-loading' });
loadingIndicator.innerHTML = `<span>Searching for "${searchQuery}"...</span>`;
// Let the UI update before continuing
await new Promise(resolve => setTimeout(resolve, 0));
} else {
groupsContainer.empty();
}
// Group files by status (with optimizations)
const statusGroups = this.getFilteredStatusGroups(searchQuery);
// Remove the loading indicator if it exists
const loadingIndicator = groupsContainer.querySelector('.note-status-loading');
if (loadingIndicator) {
loadingIndicator.remove();
}
// Render each status group
Object.entries(statusGroups).forEach(([status, files]) => {
if (files.length > 0) {
// Skip unknown status if setting enabled
if (status === 'unknown' && this.settings.excludeUnknownStatus) {
return;
}
this.renderStatusGroup(groupsContainer as HTMLElement, status, files);
}
});
// If no groups were rendered, show a message
if (groupsContainer.childElementCount === 0) {
const emptyMessage = groupsContainer.createDiv({ cls: 'note-status-empty-indicator' });
if (searchQuery) {
emptyMessage.textContent = `No notes found matching "${searchQuery}"`;
} else if (this.settings.excludeUnknownStatus) {
// Clear any existing content and use a structured layout
emptyMessage.empty();
// Add message text in its own container
emptyMessage.createDiv({
text: 'No notes with status found. Unassigned notes are currently hidden.',
cls: 'note-status-empty-message'
});
// Create separate container for the button
const btnContainer = emptyMessage.createDiv({
cls: 'note-status-button-container'
});
// Add a styled button in its own container
const showUnknownBtn = btnContainer.createEl('button', {
text: 'Show unassigned notes',
cls: 'note-status-show-unassigned-button'
});
showUnknownBtn.addEventListener('click', async () => {
this.settings.excludeUnknownStatus = false;
await this.plugin.saveSettings();
this.renderGroups(searchQuery);
});
}
}
}
/**
* Optimized method to get status groups with filtering
*/
private getFilteredStatusGroups(searchQuery = ''): Record<string, TFile[]> {
// Use the statusService but apply our own filtering for better performance
const rawGroups = this.statusService.groupFilesByStatus(searchQuery);
const filteredGroups: Record<string, TFile[]> = {};
// Filter out empty groups and respect the excludeUnknownStatus setting
Object.entries(rawGroups).forEach(([status, files]) => {
if (files.length > 0 && !(status === 'unknown' && this.settings.excludeUnknownStatus)) {
filteredGroups[status] = files;
}
});
return filteredGroups;
}
private renderStatusGroup(container: HTMLElement, status: string, files: TFile[]): void {
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' });
setIcon(collapseContainer, 'chevron-down');
// 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
const isCollapsed = this.settings.collapsedStatuses[status] ?? false;
if (isCollapsed) {
groupEl.addClass('is-collapsed');
collapseContainer.empty();
setIcon(collapseContainer, 'chevron-right');
}
titleEl.addEventListener('click', (e) => {
e.preventDefault();
const isCurrentlyCollapsed = groupEl.hasClass('is-collapsed');
// Toggle the collapsed state
if (isCurrentlyCollapsed) {
groupEl.removeClass('is-collapsed');
collapseContainer.empty();
setIcon(collapseContainer, 'chevron-down');
} else {
groupEl.addClass('is-collapsed');
collapseContainer.empty();
setIcon(collapseContainer, 'chevron-right');
}
// 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));
// Initialize pagination for this status if not already done
if (!this.paginationState.currentPage[status]) {
this.paginationState.currentPage[status] = 0;
}
// Calculate pagination
const currentPage = this.paginationState.currentPage[status];
const itemsPerPage = this.paginationState.itemsPerPage;
const totalPages = Math.ceil(files.length / itemsPerPage);
const startIndex = currentPage * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, files.length);
// Create file list items for current page only
const filesToRender = files.slice(startIndex, endIndex);
filesToRender.forEach(file => {
this.createFileListItem(childrenEl, file, status);
});
// Add pagination controls if needed
if (files.length > itemsPerPage) {
this.addPaginationControls(childrenEl, status, currentPage, totalPages, files.length);
}
}
/**
* Add pagination controls to a group
*/
private addPaginationControls(
container: HTMLElement,
status: string,
currentPage: number,
totalPages: number,
totalItems: number
): void {
const paginationEl = container.createDiv({ cls: 'note-status-pagination' });
// Add previous page button if not on first page
if (currentPage > 0) {
const prevButton = paginationEl.createEl('button', {
text: 'Previous',
cls: 'note-status-pagination-button'
});
prevButton.addEventListener('click', (e) => {
e.stopPropagation();
this.paginationState.currentPage[status] = currentPage - 1;
this.renderGroups(this.searchInput?.value.toLowerCase() || '');
});
}
// Add page indicator
paginationEl.createSpan({
text: `Page ${currentPage + 1} of ${totalPages} (${totalItems} notes)`,
cls: 'note-status-pagination-info'
});
// Add next page button if not on last page
if (currentPage < totalPages - 1) {
const nextButton = paginationEl.createEl('button', {
text: 'Next',
cls: 'note-status-pagination-button'
});
nextButton.addEventListener('click', (e) => {
e.stopPropagation();
this.paginationState.currentPage[status] = currentPage + 1;
this.renderGroups(this.searchInput?.value.toLowerCase() || '');
});
}
}
private createFileListItem(container: HTMLElement, file: TFile, status: string): void {
if (!file || !(file instanceof TFile)) return; // Skip if file is invalid
const fileEl = container.createDiv({ cls: 'nav-file' });
const fileTitleEl = fileEl.createDiv({ cls: 'nav-file-title' });
// Add file icon if in standard view
if (!this.settings.compactView) {
const fileIcon = fileTitleEl.createDiv({ cls: 'nav-file-icon' });
setIcon(fileIcon, 'file');
}
// Add file name with proper class for styling
fileTitleEl.createSpan({
text: file.basename,
cls: 'nav-file-title-content'
});
// Add status indicator
fileTitleEl.createSpan({
cls: `note-status-icon nav-file-tag status-${status}`,
text: this.statusService.getStatusIcon(status)
});
// 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(() => {
// Use the position from the event
this.plugin.statusContextMenu.showForFile(file, e);
})
);
// Add open options
menu.addItem((item) =>
item.setTitle('Open in new tab')
.setIcon('lucide-external-link')
.onClick(() => {
this.app.workspace.openLinkText(file.path, file.path, 'tab');
})
);
menu.showAtMouseEvent(e);
}
onClose(): Promise<void> {
this.containerEl.empty();
return Promise.resolve();
}
/**
* Update view when settings change
*/
updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.containerEl.toggleClass('compact-view', settings.compactView);
// Refresh the view with the current search query
this.renderGroups(this.searchInput?.value.toLowerCase() || '');
}
}

View file

@ -1,367 +0,0 @@
import { App, TFile, debounce, setTooltip } from 'obsidian';
import { FileExplorerView, NoteStatusSettings } from '../../models/types';
import { StatusService } from '../../services/status-service';
/**
* Enhanced file explorer integration for displaying status icons
* Includes performance optimizations and error handling
*/
export class ExplorerIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private iconUpdateQueue: Set<string> = new Set();
private isProcessingQueue = false;
// Debounced update function for better performance
private debouncedUpdateAll = debounce(
() => this.processUpdateQueue(),
100,
true
);
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
}
/**
* Update settings reference and refresh icons
*/
public updateSettings(settings: NoteStatusSettings): void {
const previousShowIcons = this.settings.showStatusIconsInExplorer;
this.settings = settings;
this.handleSettingsChange(previousShowIcons);
}
/**
* Handle settings change and update UI accordingly
*/
private handleSettingsChange(previousShowIcons: boolean): void {
// Update icons based on new settings
if (previousShowIcons !== this.settings.showStatusIconsInExplorer) {
if (this.settings.showStatusIconsInExplorer) {
this.updateAllFileExplorerIcons();
} else {
this.removeAllFileExplorerIcons();
}
} else if (this.settings.showStatusIconsInExplorer) {
// If setting hasn't changed but is enabled, refresh the icons
// This handles cases where custom statuses or colors changed
this.updateAllFileExplorerIcons();
}
}
/**
* Find the file explorer view
*/
private findFileExplorerView(): FileExplorerView | null {
// Try the standard method first
const leaf = this.app.workspace.getLeavesOfType('file-explorer')[0];
if (leaf && leaf.view) {
return leaf.view as FileExplorerView;
}
// If that fails, try to find it by searching all leaves
for (const leaf of this.app.workspace.getLeavesOfType('')) {
if (leaf.view && 'fileItems' in leaf.view) {
return leaf.view as FileExplorerView;
}
}
return null;
}
/**
* Add a file to the update queue
*/
public queueFileUpdate(file: TFile): void {
if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
this.iconUpdateQueue.add(file.path);
this.debouncedUpdateAll();
}
/**
* Process the queue of files that need icon updates
*/
private async processUpdateQueue(): Promise<void> {
if (this.isProcessingQueue || this.iconUpdateQueue.size === 0) return;
this.isProcessingQueue = true;
try {
const fileExplorerView = this.findFileExplorerView();
if (!fileExplorerView || !fileExplorerView.fileItems) {
// Schedule retry if view not found
setTimeout(() => this.debouncedUpdateAll(), 200);
return;
}
// Process files in the queue in batches to avoid blocking UI
const batchSize = 50;
const allPaths = Array.from(this.iconUpdateQueue);
for (let i = 0; i < allPaths.length; i += batchSize) {
const batch = allPaths.slice(i, i + batchSize);
// Process this batch
for (const filePath of batch) {
const file = this.app.vault.getFileByPath(filePath);
if (file && file instanceof TFile) {
this.updateSingleFileIcon(file, fileExplorerView);
}
this.iconUpdateQueue.delete(filePath);
}
// Let the UI breathe between batches
if (i + batchSize < allPaths.length) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
} 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) {
return; // File not in explorer view
}
const titleEl = fileItem.titleEl || fileItem.selfEl;
if (!titleEl) {
return; // No title element found
}
// Get statuses for this file - use fresh metadata cache
const statuses = this.statusService.getFileStatuses(file);
// Remove existing status icons if present (careful not to remove other elements)
this.removeExistingIcons(titleEl);
// Hide unknown status if setting is enabled
if (this.shouldHideUnknownStatus(statuses)) {
return;
}
// Add status icons WITHOUT disrupting the existing title
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 {
// Only select our specific status icon elements
const existingIcons = element.querySelectorAll('.note-status-icon, .note-status-icon-container');
existingIcons.forEach(icon => {
// Only remove elements with our classes to avoid removing title or other elements
if (icon.classList.contains('note-status-icon') ||
icon.classList.contains('note-status-icon-container')) {
icon.remove();
}
});
}
/**
* Add status icons to a title element
*/
private addStatusIcons(titleEl: HTMLElement, statuses: string[]): void {
// Create container for multiple icons - using createElement to avoid side effects
const iconContainer = document.createElement('span');
iconContainer.className = 'note-status-icon-container';
// Add all status icons
if (this.settings.useMultipleStatuses && statuses.length > 0 && statuses[0] !== 'unknown') {
// Add all icons if using multiple statuses
this.addMultipleStatusIcons(iconContainer, statuses);
} else {
// Just show primary status
this.addPrimaryStatusIcon(iconContainer, statuses);
}
// Only append if we added icons
if (iconContainer.childElementCount > 0) {
// Use appendChild to add to the end instead of replacing content
titleEl.appendChild(iconContainer);
}
}
/**
* 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 {
// Create icon element instead of modifying container directly
const iconEl = document.createElement('span');
iconEl.className = `note-status-icon nav-file-tag status-${status}`;
iconEl.textContent = this.statusService.getStatusIcon(status);
// Add tooltip with status name
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
const tooltipValue = statusObj?.description ? `${status} - ${statusObj.description}`: status;
setTooltip(iconEl, tooltipValue);
// Append to container
container.appendChild(iconEl);
}
/**
* Update a single file's icon in the file explorer (public method)
*/
public updateFileExplorerIcons(file: TFile): void {
if (!file || !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.findFileExplorerView();
if (!fileExplorer) return;
this.updateSingleFileIcon(file, fileExplorer);
} catch (error) {
console.error('Note Status: Error updating file icon directly', error);
}
}
/**
* Update all file icons in the explorer - with performance optimizations
*/
public updateAllFileExplorerIcons(): void {
// Remove all icons if setting is turned off
if (!this.settings.showStatusIconsInExplorer) {
this.removeAllFileExplorerIcons();
return;
}
// Process files in batches
const processFilesInBatches = async () => {
const files = this.app.vault.getMarkdownFiles();
const batchSize = 100; // Process 100 files at a time
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
batch.forEach(file => this.queueFileUpdate(file));
// Let the UI breathe between batches
if (i + batchSize < files.length) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
};
// Start processing
processFilesInBatches();
}
/**
* Remove all status icons from the file explorer
*/
public removeAllFileExplorerIcons(): void {
const fileExplorer = this.findFileExplorerView();
if (!fileExplorer || !fileExplorer.fileItems) return;
// Use Object.values to avoid issues with concurrent modification
Object.values(fileExplorer.fileItems).forEach((fileItem) => {
const titleEl = fileItem.titleEl || fileItem.selfEl;
if (!titleEl) return;
this.removeExistingIcons(titleEl);
});
// Clear the update queue
this.iconUpdateQueue.clear();
}
/**
* Get selected files from the file explorer
*/
public getSelectedFiles(): TFile[] {
const fileExplorer = this.findFileExplorerView();
if (!fileExplorer || !fileExplorer.fileItems) return [];
const selectedFiles: TFile[] = [];
Object.entries(fileExplorer.fileItems).forEach(([_, item]) => {
if (item.el?.classList.contains('is-selected') &&
item.file && item.file instanceof TFile &&
item.file.extension === 'md') {
selectedFiles.push(item.file);
}
});
return selectedFiles;
}
/**
* Clean up when plugin is unloaded
*/
public unload(): void {
this.removeAllFileExplorerIcons();
this.debouncedUpdateAll.cancel();
}
}

View file

@ -1,177 +0,0 @@
import { App, Menu, TFile } from 'obsidian';
import { NoteStatusSettings } from '../../models/types';
import { StatusService } from '../../services/status-service';
import { StatusDropdown } from 'ui/components/status-dropdown';
import { ExplorerIntegration } from 'ui/integrations/explorer-integration';
/**
* Handles context menu interactions for status changes
*/
export class StatusContextMenu {
private statusDropdown: StatusDropdown;
private settings: NoteStatusSettings;
private statusService: StatusService;
private explorerIntegration: ExplorerIntegration;
public app: App;
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService, statusDropdown: StatusDropdown, explorerIntegration:ExplorerIntegration) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.statusDropdown = statusDropdown;
this.explorerIntegration = explorerIntegration;
}
/**
* Updates settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = 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 a single file, show dropdown directly
if (files.length === 1) {
this.showSingleFileDropdown(files[0], position);
return;
}
// For multiple files, show menu first
this.showMultipleFilesMenu(files, position);
}
/**
* Show dropdown for a single file
*/
private showSingleFileDropdown(file: TFile, position?: { x: number; y: number }): void {
this.statusDropdown.openStatusDropdown({
position: position,
files: [file],
onStatusChange: async (statuses) => {
if (statuses.length > 0) {
await this.handleStatusUpdateForFile(file, statuses);
}
}
});
}
/**
* Show menu for multiple files
*/
private showMultipleFilesMenu(files: TFile[], position?: { x: number; y: number }): void {
const menu = new Menu();
// Add title section
menu.addItem((item) => {
item.setTitle(`Update ${files.length} files`)
.setDisabled(true);
return item;
});
// Simplified toggle-based approach for multiple files
menu.addItem((item) =>
item
.setTitle('Manage statuses...')
.setIcon('tag')
.onClick(() => {
this.statusDropdown.openStatusDropdown({
position: position,
files: files
});
})
);
// 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 {
// Ensure file is a valid TFile instance before proceeding
if (!(file instanceof TFile) || file.extension !== 'md') {
return;
}
const position = { x: event.clientX, y: event.clientY };
this.statusDropdown.openStatusDropdown({
position: position,
files: [file],
onStatusChange: async (statuses) => {
if (statuses.length > 0) {
await this.handleStatusUpdateForFile(file, statuses);
}
}
});
}
/**
* Handle status update for a specific file
*/
private async handleStatusUpdateForFile(file: TFile, statuses: string[]): Promise<void> {
// Add instanceof check for safety
if (!(file instanceof TFile) || file.extension !== 'md' || 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 - use the direct method, not batch
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(() => {
if (this.explorerIntegration) {
this.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);
}
}

View file

@ -0,0 +1,2 @@
export { StatusPaneView } from './status-pane-view';
export { StatusPaneViewController } from './status-pane-view-controller';

View file

@ -0,0 +1,170 @@
import { TFile, WorkspaceLeaf, View, Notice, App } from 'obsidian';
import { NoteStatusSettings } from '../../models/types';
import { StatusService } from '../../services/status-service';
import NoteStatus from 'main';
import { StatusPaneView } from './status-pane-view';
export class StatusPaneViewController extends View {
private renderer: StatusPaneView;
private settings: NoteStatusSettings;
private statusService: StatusService;
private plugin: NoteStatus;
private searchQuery = '';
private paginationState = {
itemsPerPage: 100,
currentPage: {} as Record<string, number>
};
static async open(app: App): Promise<void> {
const existing = app.workspace.getLeavesOfType('status-pane')[0];
if (existing) {
app.workspace.setActiveLeaf(existing);
return;
}
const leaf = app.workspace.getLeftLeaf(false);
if (leaf) {
await leaf.setViewState({ type: 'status-pane', active: true });
}
}
constructor(leaf: WorkspaceLeaf, plugin: NoteStatus) {
super(leaf);
this.plugin = plugin;
this.settings = plugin.settings;
this.statusService = plugin.statusService;
this.renderer = new StatusPaneView(this.statusService);
}
getViewType(): string { return 'status-pane'; }
getDisplayText(): string { return 'Status pane'; }
getIcon(): string { return 'tag'; }
async onOpen(): Promise<void> {
await this.setupPane();
}
onClose(): Promise<void> {
this.containerEl.empty();
return Promise.resolve();
}
private async setupPane(): Promise<void> {
const { containerEl } = this;
containerEl.empty();
containerEl.addClass('note-status-pane', 'nav-files-container');
containerEl.toggleClass('note-status-compact-view', this.settings.compactView);
this.renderer.createHeader(containerEl, this.settings.compactView, {
onSearch: (query) => {
this.paginationState = {
itemsPerPage: 100,
currentPage: {} as Record<string, number>
}
this.searchQuery = query;
this.renderGroups(query);
},
onToggleView: () => {
this.settings.compactView = !this.settings.compactView;
containerEl.toggleClass('note-status-compact-view', this.settings.compactView);
window.dispatchEvent(new CustomEvent('note-status:settings-changed'));
this.renderGroups(this.searchQuery);
},
onRefresh: async () => {
await this.renderGroups(this.searchQuery);
new Notice('Status pane refreshed');
}
});
const groupsContainer = containerEl.createDiv({ cls: 'note-status-groups-container' });
const loadingIndicator = this.renderer.createLoadingIndicator(groupsContainer);
setTimeout(async () => {
await this.renderGroups('');
loadingIndicator.remove();
}, 10);
}
private async renderGroups(searchQuery = ''): Promise<void> {
const groupsContainerEl = this.containerEl.querySelector('.note-status-groups-container') as HTMLElement;
if (!groupsContainerEl) return;
if (searchQuery) {
groupsContainerEl.empty();
this.renderer.createLoadingIndicator(groupsContainerEl, `Searching for "${searchQuery}"...`);
await new Promise(resolve => setTimeout(resolve, 0));
} else {
groupsContainerEl.empty();
}
const statusGroups = this.getFilteredStatusGroups(searchQuery);
groupsContainerEl.empty();
const hasGroups = this.renderer.renderStatusGroups(
groupsContainerEl,
statusGroups,
{
excludeUnknown: this.settings.excludeUnknownStatus,
isCompactView: this.settings.compactView,
collapsedStatuses: this.settings.collapsedStatuses,
pagination: this.paginationState,
callbacks: {
onFileClick: (file) => {
this.app.workspace.openLinkText(file.path, file.path, true);
},
onStatusToggle: (status, collapsed) => {
this.settings.collapsedStatuses[status] = collapsed;
},
onContextMenu: (e, file) => {
},
onPageChange: (status, page) => {
this.paginationState.currentPage[status] = page;
this.renderGroups(this.searchQuery);
}
}
}
);
if (!hasGroups) {
this.renderer.renderEmptyState(
groupsContainerEl,
searchQuery,
this.settings.excludeUnknownStatus,
async () => {
this.settings.excludeUnknownStatus = false;
await this.plugin.saveSettings();
this.renderGroups(searchQuery);
}
);
}
}
private getFilteredStatusGroups(searchQuery = ''): Record<string, TFile[]> {
const rawGroups = this.statusService.groupFilesByStatus(searchQuery);
const filteredGroups: Record<string, TFile[]> = {};
Object.entries(rawGroups).forEach(([status, files]) => {
if (files.length > 0) {
filteredGroups[status] = files;
}
});
return filteredGroups;
}
updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.containerEl.toggleClass('note-status-compact-view', settings.compactView);
this.renderGroups(this.searchQuery);
}
public update(): void {
// TODO: needs to be improved/fixed, every change
// this.renderGroups(this.searchQuery).then(() => {
//
// });
}
}

View file

@ -0,0 +1,245 @@
import { TFile, setIcon } from 'obsidian';
import { StatusService } from '../../services/status-service';
export class StatusPaneView{
constructor(private statusService: StatusService) {}
createHeader(container: HTMLElement, isCompactView: boolean, callbacks: {
onSearch: (query: string) => void,
onToggleView: () => void,
onRefresh: () => void
}): HTMLElement {
const header = container.createDiv({ cls: 'note-status-header' });
// Search input
const searchContainer = header.createDiv({ cls: 'note-status-search search-input-container' });
const searchWrapper = searchContainer.createDiv({ cls: 'search-input-wrapper' });
const searchIcon = searchWrapper.createEl('span', { cls: 'search-input-icon' });
setIcon(searchIcon, 'search');
const searchInput = searchWrapper.createEl('input', {
type: 'text',
placeholder: 'Search notes...',
cls: 'note-status-search-input search-input'
});
const clearBtn = searchWrapper.createEl('span', { cls: 'search-input-clear-button' });
setIcon(clearBtn, 'x');
searchInput.addEventListener('input', () => {
callbacks.onSearch(searchInput.value.toLowerCase());
clearBtn.toggleClass('is-visible', !!searchInput.value);
});
clearBtn.addEventListener('click', () => {
searchInput.value = '';
callbacks.onSearch('');
clearBtn.toggleClass('is-visible', false);
});
// Action buttons
const actions = header.createDiv({ cls: 'status-pane-actions-container' });
const viewBtn = actions.createEl('button', {
type: 'button',
title: isCompactView ? 'Switch to Standard View' : 'Switch to Compact View',
cls: 'note-status-view-toggle clickable-icon'
});
setIcon(viewBtn, isCompactView ? 'layout' : 'table');
viewBtn.addEventListener('click', callbacks.onToggleView);
const refreshBtn = actions.createEl('button', {
type: 'button',
title: 'Refresh statuses',
cls: 'note-status-actions-refresh clickable-icon'
});
setIcon(refreshBtn, 'refresh-cw');
refreshBtn.addEventListener('click', callbacks.onRefresh);
return header;
}
renderStatusGroups(container: HTMLElement, statusGroups: Record<string, TFile[]>, options: {
excludeUnknown: boolean,
isCompactView: boolean,
collapsedStatuses: Record<string, boolean>,
pagination: {
itemsPerPage: number,
currentPage: Record<string, number>
},
callbacks: {
onFileClick: (file: TFile) => void,
onStatusToggle: (status: string, collapsed: boolean) => void,
onContextMenu: (e: MouseEvent, file: TFile) => void,
onPageChange: (status: string, page: number) => void
}
}): boolean {
// Clear container first
container.empty();
let hasGroups = false;
Object.entries(statusGroups).forEach(([status, files]) => {
if (files.length > 0 && !(status === 'unknown' && options.excludeUnknown)) {
this.renderGroup(container, status, files, options);
hasGroups = true;
}
});
if (!hasGroups) {
return false;
}
return true;
}
private renderGroup(container: HTMLElement, status: string, files: TFile[], options: any): void {
const groupEl = container.createDiv({ cls: 'note-status-group nav-folder' });
const titleEl = groupEl.createDiv({ cls: 'nav-folder-title' });
const isCollapsed = options.collapsedStatuses[status] ?? false;
// Collapse indicator
const collapseIcon = titleEl.createDiv({ cls: 'note-status-collapse-indicator' });
setIcon(collapseIcon, isCollapsed ? 'chevron-right' : 'chevron-down');
// Title content
const titleContent = titleEl.createDiv({ cls: 'nav-folder-title-content' });
const statusIcon = this.statusService.getStatusIcon(status);
titleContent.createSpan({
text: `${status} ${statusIcon} (${files.length})`,
cls: `status-${status}`
});
// Set collapse state
if (isCollapsed) {
groupEl.addClass('note-status-is-collapsed');
}
// Toggle collapse on click
titleEl.addEventListener('click', (e) => {
e.preventDefault();
const newCollapsed = !groupEl.hasClass('note-status-is-collapsed');
groupEl.toggleClass('note-status-is-collapsed', newCollapsed);
collapseIcon.empty();
setIcon(collapseIcon, newCollapsed ? 'chevron-right' : 'chevron-down');
options.callbacks.onStatusToggle(status, newCollapsed);
});
// Render content
const childrenEl = groupEl.createDiv({ cls: 'nav-folder-children' });
// Pagination
const currentPage = options.pagination.currentPage[status] || 0;
const itemsPerPage = options.pagination.itemsPerPage;
const totalPages = Math.ceil(files.length / itemsPerPage);
const startIndex = currentPage * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, files.length);
// File items
files.slice(startIndex, endIndex).forEach(file => {
this.renderFileItem(childrenEl, file, status, options.isCompactView, options.callbacks);
});
// Add pagination if needed
if (files.length > itemsPerPage) {
this.addPagination(childrenEl, status, currentPage, totalPages, files.length, options.callbacks);
}
}
private renderFileItem(container: HTMLElement, file: TFile, status: string, isCompactView: boolean, callbacks: any): void {
const fileEl = container.createDiv({ cls: 'nav-file' });
const fileTitleEl = fileEl.createDiv({ cls: 'nav-file-title' });
if (!isCompactView) {
const fileIcon = fileTitleEl.createDiv({ cls: 'nav-file-icon' });
setIcon(fileIcon, 'file');
}
fileTitleEl.createSpan({
text: file.basename,
cls: 'nav-file-title-content'
});
fileTitleEl.createSpan({
cls: `note-status-icon nav-file-tag status-${status}`,
text: this.statusService.getStatusIcon(status)
});
fileEl.addEventListener('click', (e) => {
e.preventDefault();
callbacks.onFileClick(file);
});
fileEl.addEventListener('contextmenu', (e) => {
e.preventDefault();
callbacks.onContextMenu(e, file);
});
}
private addPagination(container: HTMLElement, status: string, currentPage: number, totalPages: number, totalItems: number, callbacks: any): void {
const paginationEl = container.createDiv({ cls: 'note-status-pagination' });
if (currentPage > 0) {
const prevButton = paginationEl.createEl('button', {
text: 'Previous',
cls: 'note-status-pagination-button'
});
prevButton.addEventListener('click', (e) => {
e.stopPropagation();
callbacks.onPageChange(status, currentPage - 1);
});
}
paginationEl.createSpan({
text: `Page ${currentPage + 1} of ${totalPages} (${totalItems} notes)`,
cls: 'note-status-pagination-info'
});
if (currentPage < totalPages - 1) {
const nextButton = paginationEl.createEl('button', {
text: 'Next',
cls: 'note-status-pagination-button'
});
nextButton.addEventListener('click', (e) => {
e.stopPropagation();
callbacks.onPageChange(status, currentPage + 1);
});
}
}
renderEmptyState(container: HTMLElement, searchQuery: string, excludeUnknown: boolean, onShowUnassigned: () => void): void {
const emptyMessage = container.createDiv({ cls: 'note-status-empty-indicator' });
if (searchQuery) {
emptyMessage.textContent = `No notes found matching "${searchQuery}"`;
return;
}
if (excludeUnknown) {
emptyMessage.createDiv({
text: 'No notes with status found. Unassigned notes are currently hidden.',
cls: 'note-status-empty-message'
});
const btnContainer = emptyMessage.createDiv({
cls: 'note-status-button-container'
});
const showUnknownBtn = btnContainer.createEl('button', {
text: 'Show unassigned notes',
cls: 'note-status-show-unassigned-button'
});
showUnknownBtn.addEventListener('click', onShowUnassigned);
}
}
createLoadingIndicator(container: HTMLElement, text?: string): HTMLElement {
const loadingIndicator = container.createDiv({ cls: 'note-status-loading' });
loadingIndicator.innerHTML = `<span>${text || 'Loading notes...'}</span>`;
return loadingIndicator;
}
}