mirror of
https://github.com/devonthesofa/obsidian-note-status.git
synced 2026-07-22 12:30:24 +00:00
feat: add description; fix status propagation
This commit is contained in:
parent
f8c1f6e980
commit
9724717164
8 changed files with 280 additions and 156 deletions
|
|
@ -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
36
main.ts
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface Status {
|
|||
name: string;
|
||||
icon: string;
|
||||
color?: string; // Optional color property
|
||||
description?: string; // Optional description property
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
59
styles.css
59
styles.css
|
|
@ -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 {
|
||||
|
|
|
|||
232
ui/explorer.ts
232
ui/explorer.ts
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue