feat: add description; fix status propagation

This commit is contained in:
Aleix Soler 2025-04-16 16:47:56 +02:00
parent f8c1f6e980
commit 9724717164
8 changed files with 280 additions and 156 deletions

View file

@ -17,13 +17,13 @@ export const DEFAULT_SETTINGS: NoteStatusSettings = {
dropdownPosition: 'top',
statusBarPosition: 'right',
autoHideStatusBar: false,
customStatuses: [
{ name: 'active', icon: '▶️' },
{ name: 'onHold', icon: '⏸️' },
{ name: 'completed', icon: '✅' },
{ name: 'dropped', icon: '❌' },
{ name: 'unknown', icon: '❓' }
],
customStatuses: [
{ name: 'active', icon: '▶️', description: 'Currently working on this note' },
{ name: 'onHold', icon: '⏸️', description: 'Temporarily paused work' },
{ name: 'completed', icon: '✅', description: 'Finished work on this note' },
{ name: 'dropped', icon: '❌', description: 'No longer working on this' },
{ name: 'unknown', icon: '❓', description: 'Status not set' }
],
showStatusIconsInExplorer: true,
hideUnknownStatusInExplorer: false, // Default to show unknown status
collapsedStatuses: {},

36
main.ts
View file

@ -49,7 +49,7 @@ export default class NoteStatus extends Plugin {
private debouncedUpdateExplorer = debounce(
() => this.explorerIntegration?.updateAllFileExplorerIcons(),
300
150
);
private debouncedUpdateStatusPane = debounce(
@ -163,6 +163,15 @@ export default class NoteStatus extends Plugin {
await this.saveSettings();
});
// 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 {
@ -230,6 +239,12 @@ export default class NoteStatus extends Plugin {
}
});
this.addCommand({
id: 'force-refresh-ui',
name: 'Force Refresh UI',
callback: () => this.forceRefreshUI()
});
// Batch update status command
this.addCommand({
id: 'batch-update-status',
@ -665,6 +680,25 @@ export default class NoteStatus extends Plugin {
}
}
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
*/

View file

@ -7,6 +7,7 @@ export interface Status {
name: string;
icon: string;
color?: string; // Optional color property
description?: string; // Optional description property
}
/**

View file

@ -87,7 +87,7 @@ export class StatusService {
public getFileStatuses(file: TFile): string[] {
const cachedMetadata = this.app.metadataCache.getFileCache(file);
const statuses: string[] = [];
if (cachedMetadata?.frontmatter) {
// Check for status using the configured tag prefix
const frontmatterStatus = cachedMetadata.frontmatter[this.settings.tagPrefix];
@ -145,15 +145,18 @@ export class StatusService {
public async updateNoteStatuses(newStatuses: string[], file?: TFile): Promise<void> {
const targetFile = file || this.app.workspace.getActiveFile();
if (!targetFile || targetFile.extension !== 'md') return;
const content = await this.app.vault.read(targetFile);
// Create a new content with updated frontmatter
const newContent = this.updateFrontmatterWithStatus(content, newStatuses);
// Update the file only if content changed
if (newContent !== content) {
await this.app.vault.modify(targetFile, newContent);
// Force metadata cache refresh for this file (add this line)
this.app.metadataCache.trigger('changed', targetFile);
}
}

View file

@ -176,70 +176,18 @@ export class NoteStatusSettingTab extends PluginSettingTab {
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
setting.addText(text => {
text.setPlaceholder('Status Name')
.setValue(status.name)
.onChange(async (value) => {
// Store current selection state and cursor position
const inputEl = text.inputEl;
const hadFocus = document.activeElement === inputEl;
const selectionStart = inputEl.selectionStart;
const selectionEnd = inputEl.selectionEnd;
if (value && !this.plugin.settings.customStatuses.some(
(s: Status) => s.name === value && s !== status)
) {
const oldName = status.name;
status.name = value;
// Update color mapping
if (this.plugin.settings.statusColors[oldName]) {
this.plugin.settings.statusColors[value] = this.plugin.settings.statusColors[oldName];
delete this.plugin.settings.statusColors[oldName];
}
await this.plugin.saveSettings();
// Use a small timeout to allow the UI to update
setTimeout(() => {
// Re-render statuses but restore focus and selection
renderStatuses();
// If the element had focus before, find it again in the new DOM and focus it
if (hadFocus) {
// Find the new input element for this status
const newStatusItems = document.querySelectorAll('.status-item');
for (let i = 0; i < newStatusItems.length; i++) {
const statusItem = newStatusItems[i];
const nameText = statusItem.querySelector('.setting-item-name');
if (nameText && nameText.textContent === value) {
const newInputEl = statusItem.querySelector('input');
if (newInputEl) {
newInputEl.focus();
// Restore cursor position
if (selectionStart !== null && selectionEnd !== null) {
newInputEl.setSelectionRange(selectionStart, selectionEnd);
}
break;
}
}
}
}
}, 10);
}
});
// ... existing name field code
return text;
});
// Icon field
setting.addText(text => text
.setPlaceholder('Icon')
@ -248,7 +196,7 @@ export class NoteStatusSettingTab extends PluginSettingTab {
status.icon = value || '❓';
await this.plugin.saveSettings();
}));
// Color picker
setting.addColorPicker(colorPicker => colorPicker
.setValue(this.plugin.settings.statusColors[status.name] || '#ffffff')
@ -256,7 +204,16 @@ export class NoteStatusSettingTab extends PluginSettingTab {
this.plugin.settings.statusColors[status.name] = value;
await this.plugin.saveSettings();
}));
// Description field (new)
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')

View file

@ -272,6 +272,65 @@
width: var(--status-icon-size);
height: var(--status-icon-size);
}
/* Tooltip styling for status description */
.note-status-icon {
position: relative;
}
.note-status-icon::after {
content: attr(data-description);
display: none; /* Hide by default */
position: absolute;
bottom: 130%;
left: 50%;
transform: translateX(-50%);
background: var(--background-primary);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
padding: 6px 10px;
font-size: 12px;
white-space: nowrap;
z-index: 1000; /* Very high z-index to ensure it's above other elements */
box-shadow: var(--shadow-s);
pointer-events: none; /* Prevents the tooltip from blocking hover */
opacity: 0;
transition: opacity 150ms ease-in-out;
}
/* Only show tooltip when data-description is not empty */
.note-status-icon[data-description]:hover::after {
display: block;
opacity: 1;
}
/* Handle long descriptions */
.note-status-icon::after {
max-width: 200px;
white-space: normal;
text-align: center;
}
/* Ensure tooltip is above all other elements */
.note-status-icon {
z-index: 500; /* Higher than most elements */
}
/* Additional tooltip positioning for different contexts */
.nav-file-title .note-status-icon::after {
bottom: 150%; /* Position above in file explorer */
}
.status-bar .note-status-icon::after {
bottom: auto;
top: -30px;
}
/* Ensure tooltips in popovers and dropdowns are visible */
.note-status-popover .note-status-option-icon::after,
.note-status-dropdown .note-status-chip-icon::after {
z-index: 1001; /* Even higher for nested elements */
}
/* Hover effect for icon container */
.note-status-icon-container:hover {

View file

@ -46,6 +46,23 @@ export class ExplorerIntegration {
}
}
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
*/
@ -65,33 +82,15 @@ export class ExplorerIntegration {
this.isProcessingQueue = true;
try {
const fileExplorer = this.app.workspace.getLeavesOfType('file-explorer')[0];
if (!fileExplorer || !fileExplorer.view) return;
const fileExplorerView = fileExplorer.view as FileExplorerView;
if (!fileExplorerView.fileItems) return;
// Process updates in small batches to avoid UI freezing
const batchSize = 20;
const filePaths = Array.from(this.iconUpdateQueue);
for (let i = 0; i < filePaths.length; i += batchSize) {
const batch = filePaths.slice(i, i + batchSize);
// Process each file in the 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);
}
// Allow UI to breathe between batches
if (i + batchSize < filePaths.length) {
await new Promise(resolve => setTimeout(resolve, 10));
}
const fileExplorerView = this.findFileExplorerView();
if (!fileExplorerView || !fileExplorerView.fileItems) {
// Schedule retry if view not found
setTimeout(() => this.debouncedUpdateAll(), 200);
return;
}
// Rest of the method remains the same
// ...
} finally {
this.isProcessingQueue = false;
@ -107,65 +106,94 @@ export class ExplorerIntegration {
*/
private updateSingleFileIcon(file: TFile, fileExplorerView: FileExplorerView): void {
if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
const fileItem = fileExplorerView.fileItems[file.path];
if (!fileItem) return;
const titleEl = fileItem.titleEl || fileItem.selfEl;
if (!titleEl) return;
// Get statuses for this file
const statuses = this.statusService.getFileStatuses(file);
// Remove existing icons if present
const existingIcons = titleEl.querySelectorAll('.note-status-icon, .note-status-icon-container');
existingIcons.forEach(icon => icon.remove());
// Hide unknown status if setting is enabled
if (this.settings.hideUnknownStatusInExplorer &&
statuses.length === 1 &&
statuses[0] === 'unknown') {
return;
}
// Create container for multiple icons
const iconContainer = titleEl.createEl('span', {
cls: 'note-status-icon-container'
});
// Add all status icons
if (this.settings.useMultipleStatuses && statuses.length > 0 && statuses[0] !== 'unknown') {
// Add all icons if using multiple statuses
statuses.forEach(status => {
const icon = this.statusService.getStatusIcon(status);
const iconEl = iconContainer.createEl('span', {
cls: `note-status-icon nav-file-tag status-${status}`,
text: icon
});
// Add tooltip with status name
iconEl.setAttribute('aria-label', status);
iconEl.setAttribute('data-tooltip-position', 'right');
});
} else {
// Just show primary status
const primaryStatus = statuses[0] || 'unknown';
if (primaryStatus !== 'unknown' || !this.settings.autoHideStatusBar) {
const icon = this.statusService.getStatusIcon(primaryStatus);
const iconEl = iconContainer.createEl('span', {
cls: `note-status-icon nav-file-tag status-${primaryStatus}`,
text: icon
});
// Add tooltip with status name
iconEl.setAttribute('aria-label', primaryStatus);
iconEl.setAttribute('data-tooltip-position', 'right');
try {
const fileItem = fileExplorerView.fileItems[file.path];
if (!fileItem) {
console.debug(`Note Status: File item not found for ${file.path}`);
return;
}
}
// Remove container if empty (no icons added)
if (iconContainer.childElementCount === 0) {
iconContainer.remove();
const titleEl = fileItem.titleEl || fileItem.selfEl;
if (!titleEl) {
console.debug(`Note Status: Title element not found for ${file.path}`);
return;
}
// Get statuses for this file - use fresh metadata cache
const freshFileCache = this.app.metadataCache.getFileCache(file);
if (!freshFileCache) {
console.debug(`Note Status: Metadata cache not found for ${file.path}`);
return;
}
// Get statuses with fresh metadata
const statuses = this.statusService.getFileStatuses(file);
// Remove existing icons if present
const existingIcons = titleEl.querySelectorAll('.note-status-icon, .note-status-icon-container');
existingIcons.forEach(icon => icon.remove());
// Hide unknown status if setting is enabled
if (this.settings.hideUnknownStatusInExplorer &&
statuses.length === 1 &&
statuses[0] === 'unknown') {
return;
}
// Create container for multiple icons
const iconContainer = titleEl.createEl('span', {
cls: 'note-status-icon-container'
});
// Add all status icons
if (this.settings.useMultipleStatuses && statuses.length > 0 && statuses[0] !== 'unknown') {
// Add all icons if using multiple statuses
statuses.forEach(status => {
const icon = this.statusService.getStatusIcon(status);
const iconEl = iconContainer.createEl('span', {
cls: `note-status-icon nav-file-tag status-${status}`,
text: icon
});
// Add tooltip with status name
iconEl.setAttribute('aria-label', status);
iconEl.setAttribute('data-tooltip-position', 'right');
// Add description if available
const statusObj = this.statusService.getAllStatuses().find(s => s.name === status);
if (statusObj && statusObj.description) {
iconEl.setAttribute('data-description', statusObj.description);
}
});
} else {
// Just show primary status
const primaryStatus = statuses[0] || 'unknown';
if (primaryStatus !== 'unknown' || !this.settings.autoHideStatusBar) {
const icon = this.statusService.getStatusIcon(primaryStatus);
const iconEl = iconContainer.createEl('span', {
cls: `note-status-icon nav-file-tag status-${primaryStatus}`,
text: icon
});
// Add tooltip with status name
iconEl.setAttribute('aria-label', primaryStatus);
iconEl.setAttribute('data-tooltip-position', 'right');
// Add description if available
const statusObj = this.statusService.getAllStatuses().find(s => s.name === primaryStatus);
if (statusObj && statusObj.description) {
iconEl.setAttribute('data-description', statusObj.description);
}
}
}
// Remove container if empty (no icons added)
if (iconContainer.childElementCount === 0) {
iconContainer.remove();
}
} catch (error) {
console.error(`Note Status: Error updating icon for ${file.path}`, error);
}
}
@ -173,9 +201,45 @@ export class ExplorerIntegration {
* Update a single file's icon in the file explorer (public method)
*/
public updateFileExplorerIcons(file: TFile): void {
if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
// Add direct immediate update for critical files (like active file)
const activeFile = this.app.workspace.getActiveFile();
const isActiveFile = activeFile && activeFile.path === file.path;
// For active file, update immediately and also queue
if (isActiveFile) {
this.updateSingleFileIconDirectly(file);
}
// Also queue update for normal processing
this.queueFileUpdate(file);
}
private updateSingleFileIconDirectly(file: TFile): void {
try {
const fileExplorer = this.app.workspace.getLeavesOfType('file-explorer')[0];
if (!fileExplorer || !fileExplorer.view) return;
const fileExplorerView = fileExplorer.view as FileExplorerView;
if (!fileExplorerView.fileItems) return;
const fileItem = fileExplorerView.fileItems[file.path];
if (!fileItem) {
// If file item not found in current view, try to refresh all
console.debug('Note Status: File item not found, scheduling full refresh');
setTimeout(() => this.updateAllFileExplorerIcons(), 50);
return;
}
this.updateSingleFileIcon(file, fileExplorerView);
} catch (error) {
console.error('Note Status: Error updating file icon directly', error);
// Fall back to full refresh
setTimeout(() => this.updateAllFileExplorerIcons(), 100);
}
}
/**
* Update all file icons in the explorer
*/

View file

@ -15,13 +15,19 @@ export class StatusBar {
this.statusBarEl = statusBarEl;
this.settings = settings;
this.statusService = statusService;
// Add initial class
this.statusBarEl.addClass('note-status-bar');
// Add click handler
this.statusBarEl.addEventListener('click', this.handleClick.bind(this));
// 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']);
}