refactor: folder structure and clean code

This commit is contained in:
Aleix Soler 2025-04-05 11:56:52 +02:00
parent d4385234a0
commit 382ddf9b56
16 changed files with 2172 additions and 1184 deletions

40
constants/defaults.ts Normal file
View file

@ -0,0 +1,40 @@
import { NoteStatusSettings } from '../models/types';
/**
* Default plugin settings
*/
export const DEFAULT_SETTINGS: NoteStatusSettings = {
statusColors: {
active: 'var(--text-success)',
onHold: 'var(--text-warning)',
completed: 'var(--text-accent)',
dropped: 'var(--text-error)',
unknown: 'var(--text-muted)'
},
showStatusDropdown: true,
showStatusBar: true,
dropdownPosition: 'top',
statusBarPosition: 'right',
autoHideStatusBar: false,
customStatuses: [
{ name: 'active', icon: '▶️' },
{ name: 'onHold', icon: '⏸️' },
{ name: 'completed', icon: '✅' },
{ name: 'dropped', icon: '❌' },
{ name: 'unknown', icon: '❓' }
],
showStatusIconsInExplorer: true,
collapsedStatuses: {},
compactView: false
};
/**
* Default colors in hexadecimal format for backup and reset
*/
export const DEFAULT_COLORS: Record<string, string> = {
active: '#00ff00', // Green for success
onHold: '#ffaa00', // Orange for warning
completed: '#00aaff', // Blue for accent
dropped: '#ff0000', // Red for error
unknown: '#888888' // Gray for muted
};

24
constants/icons.ts Normal file
View file

@ -0,0 +1,24 @@
/**
* SVG icon definitions
*/
export const ICONS = {
statusPane: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h10v2H7v-2z"/>
</svg>`,
search: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="search-icon">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>`,
clear: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>`,
standardView: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`,
compactView: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="3" x2="9" y2="21"></line></svg>`,
refresh: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38"/></svg>`,
collapseDown: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>`,
collapseRight: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>`,
file: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`
};

1326
main.ts

File diff suppressed because it is too large Load diff

37
models/types.ts Normal file
View file

@ -0,0 +1,37 @@
import { TFile, View } from 'obsidian';
/**
* Defines a status with name and icon
*/
export interface Status {
name: string;
icon: string;
}
/**
* Plugin settings interface
*/
export interface NoteStatusSettings {
statusColors: Record<string, string>;
showStatusDropdown: boolean;
showStatusBar: boolean;
dropdownPosition: 'top' | 'bottom';
statusBarPosition: 'left' | 'right';
autoHideStatusBar: boolean;
customStatuses: Status[];
showStatusIconsInExplorer: boolean;
collapsedStatuses: Record<string, boolean>;
compactView: boolean;
}
/**
* Extended interface for file explorer view
*/
export interface FileExplorerView extends View {
fileItems: Record<string, {
el?: HTMLElement;
file?: TFile;
titleEl?: HTMLElement;
selfEl?: HTMLElement;
}>;
}

169
services/status-service.ts Normal file
View file

@ -0,0 +1,169 @@
import { App, TFile, Editor, Notice } from 'obsidian';
import { NoteStatusSettings } from '../models/types';
/**
* Service for handling note status operations
*/
export class StatusService {
private app: App;
private settings: NoteStatusSettings;
constructor(app: App, settings: NoteStatusSettings) {
this.app = app;
this.settings = settings;
}
/**
* Updates the settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
/**
* Get the status of a file from its metadata
*/
public getFileStatus(file: TFile): string {
const cachedMetadata = this.app.metadataCache.getFileCache(file);
let status = 'unknown';
if (cachedMetadata?.frontmatter?.status) {
const frontmatterStatus = cachedMetadata.frontmatter.status.toLowerCase();
const matchingStatus = this.settings.customStatuses.find(s =>
s.name.toLowerCase() === frontmatterStatus);
if (matchingStatus) status = matchingStatus.name;
}
return status;
}
/**
* Get the icon for a given status
*/
public getStatusIcon(status: string): string {
const customStatus = this.settings.customStatuses.find(
s => s.name.toLowerCase() === status.toLowerCase()
);
return customStatus ? customStatus.icon : '❓';
}
/**
* Update the status of a note
*/
public async updateNoteStatus(newStatus: 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);
let newContent = content;
// Handle frontmatter update
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?/);
if (frontmatterMatch) {
const frontmatter = frontmatterMatch[1];
if (frontmatter.includes('status:')) {
newContent = content.replace(
/^---\n([\s\S]*?)status:\s*[^\n]+([\s\S]*?)\n---\n?/,
`---\n$1status: ${newStatus}$2\n---\n`
);
} else {
newContent = content.replace(
/^---\n([\s\S]*?)\n---\n?/,
`---\n$1\nstatus: ${newStatus}\n---\n`
);
}
} else {
newContent = `---\nstatus: ${newStatus}\n---\n${content.trim()}`;
}
// Clean up excess newlines
newContent = newContent.replace(/\n{3,}/g, '\n\n');
// Update the file
await this.app.vault.modify(targetFile, newContent);
}
/**
* Batch update multiple files' statuses
*/
public async batchUpdateStatus(files: TFile[], newStatus: string): Promise<void> {
if (files.length === 0) {
new Notice('No files selected');
return;
}
for (const file of files) {
await this.updateNoteStatus(newStatus, file);
}
new Notice(`Updated status of ${files.length} file${files.length === 1 ? '' : 's'} to ${newStatus}`);
}
/**
* Insert status metadata in the editor
*/
public insertStatusMetadataInEditor(editor: Editor): void {
const content = editor.getValue();
const statusMetadata = 'status: unknown';
// Check if frontmatter exists
const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/);
if (frontMatterMatch) {
const frontMatter = frontMatterMatch[1];
let updatedFrontMatter = frontMatter;
// Check if status already exists in frontmatter
if (/^status:/.test(frontMatter)) {
updatedFrontMatter = frontMatter.replace(/^status: .*/m, statusMetadata);
} else {
updatedFrontMatter = `${frontMatter}\n${statusMetadata}`;
}
const updatedContent = content.replace(/^---\n([\s\S]+?)\n---/, `---\n${updatedFrontMatter}\n---`);
editor.setValue(updatedContent);
} else {
// Create new frontmatter if it doesn't exist
const newFrontMatter = `---\n${statusMetadata}\n---\n${content}`;
editor.setValue(newFrontMatter);
}
}
/**
* Get all markdown files with optional filtering
*/
public getMarkdownFiles(searchQuery = ''): TFile[] {
const files = this.app.vault.getMarkdownFiles();
if (!searchQuery) {
return files;
}
return files.filter(file =>
file.basename.toLowerCase().includes(searchQuery.toLowerCase())
);
}
/**
* Group files by their status
*/
public groupFilesByStatus(searchQuery = ''): Record<string, TFile[]> {
const statusGroups: Record<string, TFile[]> = {};
// Initialize groups for each status
this.settings.customStatuses.forEach(status => {
statusGroups[status.name] = [];
});
// Get all markdown files and filter by search query
const files = this.getMarkdownFiles(searchQuery);
for (const file of files) {
const status = this.getFileStatus(file);
statusGroups[status].push(file);
}
return statusGroups;
}
}

99
services/style-service.ts Normal file
View file

@ -0,0 +1,99 @@
import { NoteStatusSettings } from '../models/types';
/**
* Handles dynamic styling for the plugin
*/
export class StyleService {
private dynamicStyleEl?: HTMLStyleElement;
private settings: NoteStatusSettings;
constructor(settings: NoteStatusSettings) {
this.settings = settings;
this.initializeDynamicStyles();
}
/**
* 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();
}
/**
* Updates the dynamic styles based on current settings
*/
public updateDynamicStyles(): void {
if (!this.dynamicStyleEl) {
this.initializeDynamicStyles();
return;
}
// Generate CSS for status colors
let css = '';
for (const [status, color] of Object.entries(this.settings.statusColors)) {
css += `
.status-${status} {
color: ${color} !important;
}
.note-status-bar .note-status-${status},
.nav-file-title .note-status-${status} {
color: ${color} !important;
}
`;
}
// Add modern styling for the batch modal
css += `
.note-status-batch-modal {
max-width: 500px;
}
.note-status-modal-search {
margin-bottom: 10px;
}
.note-status-modal-search-input {
width: 100%;
padding: var(--input-padding);
border-radius: var(--input-radius);
}
.note-status-file-select,
.note-status-status-select {
width: 100%;
margin-bottom: 10px;
border-radius: var(--input-radius);
}
.note-status-modal-buttons {
display: flex;
justify-content: space-between;
margin-top: 15px;
}
`;
this.dynamicStyleEl.textContent = css;
}
/**
* Cleans up styles when plugin is unloaded
*/
public unload(): void {
if (this.dynamicStyleEl) {
this.dynamicStyleEl.remove();
this.dynamicStyleEl = undefined;
}
}
}

239
settings/settings-tab.ts Normal file
View file

@ -0,0 +1,239 @@
import { App, PluginSettingTab, Setting, Notice } from 'obsidian';
import { Status } from '../models/types';
/**
* Settings tab for the Note Status plugin
*/
export class NoteStatusSettingTab extends PluginSettingTab {
plugin: any;
constructor(app: App, plugin: any) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
// Header
containerEl.createEl('h2', { text: 'Note Status Settings' });
// UI section
containerEl.createEl('h3', { text: 'UI Settings' });
// Status dropdown settings
new Setting(containerEl)
.setName('Show status dropdown')
.setDesc('Display status dropdown in notes')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showStatusDropdown)
.onChange(async (value) => {
this.plugin.settings.showStatusDropdown = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Dropdown position')
.setDesc('Where to place the status dropdown')
.addDropdown(dropdown => dropdown
.addOption('top', 'Top')
.addOption('bottom', 'Bottom')
.setValue(this.plugin.settings.dropdownPosition)
.onChange(async (value: 'top' | 'bottom') => {
this.plugin.settings.dropdownPosition = value;
await this.plugin.saveSettings();
}));
// 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('Status bar position')
.setDesc('Align the status bar text')
.addDropdown(dropdown => dropdown
.addOption('left', 'Left')
.addOption('right', 'Right')
.setValue(this.plugin.settings.statusBarPosition)
.onChange(async (value: 'left' | 'right') => {
this.plugin.settings.statusBarPosition = 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();
}));
// 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();
}));
// Status management section
containerEl.createEl('h3', { text: 'Custom Statuses' });
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
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);
}
});
return text;
});
// 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();
}));
// 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();
}));
// Reset colors
new Setting(containerEl)
.setName('Reset default status colors')
.setDesc('Restore the default colors for predefined statuses')
.addButton(button => button
.setButtonText('Reset Colors')
.setWarning()
.onClick(async () => {
await this.plugin.resetDefaultColors();
renderStatuses();
new Notice('Default status colors restored');
}));
}
}

View file

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

85
ui/context-menus.ts Normal file
View file

@ -0,0 +1,85 @@
import { App, Menu, TFile } from 'obsidian';
import { NoteStatusSettings } from '../models/types';
import { StatusService } from '../services/status-service';
/**
* Handles context menu interactions for status changes
*/
export class StatusContextMenu {
private settings: NoteStatusSettings;
private statusService: StatusService;
public app: App;
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
}
/**
* 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 {
const menu = new Menu();
// Add status options to menu
this.settings.customStatuses
.filter(status => status.name !== 'unknown')
.forEach(status => {
menu.addItem((item) =>
item
.setTitle(`${status.name} ${status.icon}`)
.setIcon('tag')
.onClick(async () => {
await this.statusService.batchUpdateStatus(files, status.name);
// Dispatch event for UI update
window.dispatchEvent(new CustomEvent('note-status:refresh-ui'));
})
);
});
// Show the menu
if (position) {
menu.showAtPosition(position);
} else {
// Use a default position at mouse event
menu.showAtMouseEvent(new MouseEvent('contextmenu'));
}
}
/**
* Shows a context menu for a single file
*/
public showForFile(file: TFile, event: MouseEvent): void {
const menu = new Menu();
// Add status change options
menu.addItem((item) =>
item.setTitle('Change Status')
.setIcon('tag')
.onClick(() => {
this.showForFiles([file]);
})
);
// Add other file options
// Example: Open in new tab
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(event);
}
}

135
ui/explorer.ts Normal file
View file

@ -0,0 +1,135 @@
import { App, TFile } from 'obsidian';
import { FileExplorerView, NoteStatusSettings } from '../models/types';
import { StatusService } from '../services/status-service';
/**
* Handles file explorer integration for displaying status icons
*/
export class ExplorerIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
}
/**
* Update settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
// Update icons based on new settings
if (settings.showStatusIconsInExplorer) {
this.updateAllFileExplorerIcons();
} else {
this.removeAllFileExplorerIcons();
}
}
/**
* Update a single file's icon in the file explorer
*/
public updateFileExplorerIcons(file: TFile): void {
if (!this.settings.showStatusIconsInExplorer || file.extension !== 'md') return;
const status = this.statusService.getFileStatus(file);
const fileExplorer = this.app.workspace.getLeavesOfType('file-explorer')[0];
if (fileExplorer && fileExplorer.view) {
// Cast fileExplorer.view to FileExplorerView
const fileExplorerView = fileExplorer.view as FileExplorerView;
if (fileExplorerView.fileItems) {
const fileItem = fileExplorerView.fileItems[file.path];
if (fileItem) {
const titleEl = fileItem.titleEl || fileItem.selfEl;
if (titleEl) {
// Remove existing icon if present
const existingIcon = titleEl.querySelector('.note-status-icon');
if (existingIcon) existingIcon.remove();
// Add new icon
titleEl.createEl('span', {
cls: `note-status-icon nav-file-tag status-${status}`,
text: this.statusService.getStatusIcon(status)
});
}
}
}
}
}
/**
* Update all file icons in the explorer
*/
public updateAllFileExplorerIcons(): void {
// Remove all icons if setting is turned off
if (!this.settings.showStatusIconsInExplorer) {
this.removeAllFileExplorerIcons();
return;
}
// Update icons for all markdown files
const files = this.app.vault.getMarkdownFiles();
files.forEach(file => this.updateFileExplorerIcons(file));
}
/**
* Remove all status icons from the file explorer
*/
public removeAllFileExplorerIcons(): void {
const fileExplorer = this.app.workspace.getLeavesOfType('file-explorer')[0];
if (fileExplorer && fileExplorer.view) {
// Cast fileExplorer.view to FileExplorerView
const fileExplorerView = fileExplorer.view as FileExplorerView;
if (fileExplorerView.fileItems) {
Object.values(fileExplorerView.fileItems).forEach((fileItem) => {
const titleEl = fileItem.titleEl || fileItem.selfEl;
if (titleEl) {
const existingIcon = titleEl.querySelector('.note-status-icon');
if (existingIcon) existingIcon.remove();
}
});
}
}
}
/**
* Get selected files from the file explorer
*/
public getSelectedFiles(): TFile[] {
const fileExplorer = this.app.workspace.getLeavesOfType('file-explorer')[0];
if (!fileExplorer || !fileExplorer.view) {
console.log('File explorer not found or no file items');
return [];
}
const fileExplorerView = fileExplorer.view as FileExplorerView;
if (!fileExplorerView.fileItems) {
console.log('No file items found');
return [];
}
const selectedFiles: TFile[] = [];
Object.entries(fileExplorerView.fileItems).forEach(([_, item]) => {
if (item.el?.classList.contains('is-selected') && item.file instanceof TFile && item.file.extension === 'md') {
selectedFiles.push(item.file);
}
});
return selectedFiles;
}
/**
* Clean up when plugin is unloaded
*/
public unload(): void {
this.removeAllFileExplorerIcons();
}
}

120
ui/modals.ts Normal file
View file

@ -0,0 +1,120 @@
import { App, Modal, TFile, Notice } from 'obsidian';
import { NoteStatusSettings } from '../models/types';
import { StatusService } from '../services/status-service';
/**
* Modal for batch updating statuses
*/
export class BatchStatusModal extends Modal {
settings: NoteStatusSettings;
statusService: StatusService;
app: App;
constructor(app: App, settings: NoteStatusSettings, statusService: StatusService) {
super(app);
this.app = app;
this.settings = settings;
this.statusService = statusService;
}
onOpen() {
const { contentEl } = this;
contentEl.createEl('h2', { text: 'Batch Update Note Status' });
contentEl.addClass('note-status-batch-modal');
// Create file selection with search filter
const searchContainer = contentEl.createDiv({ cls: 'note-status-modal-search' });
const searchInput = searchContainer.createEl('input', {
type: 'text',
placeholder: 'Filter files...',
cls: 'note-status-modal-search-input'
});
// File selection container
const fileSelectContainer = contentEl.createDiv({ cls: 'note-status-file-select-container' });
const fileSelect = fileSelectContainer.createEl('select', {
cls: 'note-status-file-select',
attr: { multiple: 'true', size: '10' }
});
// Get all markdown files and populate select
const mdFiles = this.app.vault.getMarkdownFiles();
const populateFiles = (filter = '') => {
fileSelect.empty();
mdFiles
.filter(file => !filter || file.path.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => a.path.localeCompare(b.path))
.forEach(file => {
const status = this.statusService.getFileStatus(file);
const option = fileSelect.createEl('option', {
text: `${file.path} ${this.statusService.getStatusIcon(status)}`,
value: file.path
});
option.classList.add(`status-${status}`);
});
};
populateFiles();
// Add search functionality
searchInput.addEventListener('input', () => {
populateFiles(searchInput.value);
});
// Status selection
const statusSelectContainer = contentEl.createDiv({ cls: 'note-status-status-select-container' });
const statusSelect = statusSelectContainer.createEl('select', { cls: 'note-status-status-select' });
// Add status options
this.settings.customStatuses
.filter(status => status.name !== 'unknown')
.forEach(status => {
const option = statusSelect.createEl('option', {
text: `${status.name} ${status.icon}`,
value: status.name
});
option.classList.add(`status-${status.name}`);
});
// Add action buttons
const buttonContainer = contentEl.createDiv({ cls: 'note-status-modal-buttons' });
// Select all button
const selectAllButton = buttonContainer.createEl('button', {
text: 'Select All',
cls: 'note-status-select-all'
});
selectAllButton.addEventListener('click', () => {
for (const option of Array.from(fileSelect.options)) {
option.selected = true;
}
});
// Apply button
const applyButton = buttonContainer.createEl('button', {
text: 'Apply Status',
cls: 'mod-cta'
});
applyButton.addEventListener('click', async () => {
const selectedFiles = Array.from(fileSelect.selectedOptions)
.map(opt => mdFiles.find(f => f.path === opt.value))
.filter(Boolean) as TFile[];
if (selectedFiles.length === 0) {
new Notice('No files selected');
return;
}
const newStatus = statusSelect.value;
await this.statusService.batchUpdateStatus(selectedFiles, newStatus);
this.close();
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

107
ui/status-bar.ts Normal file
View file

@ -0,0 +1,107 @@
import { Notice } 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 currentStatus = '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 click handler
this.statusBarEl.addEventListener('click', this.handleClick.bind(this));
// Initial render
this.update('unknown');
}
/**
* Handle click on status bar
*/
private handleClick(): void {
this.settings.showStatusDropdown = !this.settings.showStatusDropdown;
// Notify UI listeners through an event
window.dispatchEvent(new CustomEvent('note-status:refresh-dropdown'));
new Notice(`Status dropdown ${this.settings.showStatusDropdown ? 'shown' : 'hidden'}`);
}
/**
* Update the status bar with a new status
*/
public update(status: string): void {
this.currentStatus = status;
this.render();
}
/**
* Render the status bar based on current settings and status
*/
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;
}
// Add left class if needed
if (this.settings.statusBarPosition === 'left') {
this.statusBarEl.addClass('left');
}
// Create status text
this.statusBarEl.createEl('span', {
text: `Status: ${this.currentStatus}`,
cls: `note-status-${this.currentStatus}`
});
// Create status icon
this.statusBarEl.createEl('span', {
text: this.statusService.getStatusIcon(this.currentStatus),
cls: `note-status-icon status-${this.currentStatus}`
});
// Handle auto-hide behavior
if (this.settings.autoHideStatusBar && this.currentStatus === 'unknown') {
this.statusBarEl.addClass('auto-hide');
setTimeout(() => {
if (this.currentStatus === 'unknown' && 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();
}
}

174
ui/status-dropdown.ts Normal file
View file

@ -0,0 +1,174 @@
import { MarkdownView, Notice, Editor, Menu } from 'obsidian';
import { NoteStatusSettings } from '../models/types';
import { StatusService } from '../services/status-service';
/**
* Handles the status dropdown functionality
*/
export class StatusDropdown {
private app: any;
private settings: NoteStatusSettings;
private statusService: StatusService;
private dropdownContainer?: HTMLElement;
private currentStatus = 'unknown';
constructor(app: any, settings: NoteStatusSettings, statusService: StatusService) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
}
/**
* Updates the dropdown UI based on current settings
*/
public update(currentStatus: string): void {
this.currentStatus = currentStatus;
this.render();
}
/**
* Updates settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.render();
}
/**
* Render or remove the dropdown based on settings
*/
public render(): void {
// Remove existing dropdown if setting is turned off
if (!this.settings.showStatusDropdown) {
if (this.dropdownContainer) {
this.dropdownContainer.remove();
this.dropdownContainer = undefined;
}
return;
}
// Check for active markdown view
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) {
if (this.dropdownContainer) {
this.dropdownContainer.remove();
this.dropdownContainer = undefined;
}
return;
}
// Create or update the dropdown container
const contentEl = view.contentEl;
if (!this.dropdownContainer) {
this.dropdownContainer = this.settings.dropdownPosition === 'top'
? contentEl.insertBefore(document.createElement('div'), contentEl.firstChild)
: contentEl.appendChild(document.createElement('div'));
if (this.dropdownContainer) {
this.dropdownContainer.addClass('note-status-dropdown', this.settings.dropdownPosition);
}
}
// Ensure dropdown container exists before continuing
if (!this.dropdownContainer) return;
// Populate the dropdown
this.dropdownContainer.empty();
// Add label
this.dropdownContainer.createEl('span', { text: 'Status:', cls: 'note-status-label' });
// Add select element
const select = this.dropdownContainer.createEl('select', { cls: 'note-status-select dropdown' });
// Add status options (excluding 'unknown')
this.settings.customStatuses
.forEach(status => {
const option = select.createEl('option', {
text: `${status.name} ${status.icon}`,
value: status.name
});
if (status.name === 'unknown') option.disabled = true;
if (status.name === this.currentStatus) option.selected = true;
});
// Add change event listener
select.addEventListener('change', async (e) => {
const newStatus = (e.target as HTMLSelectElement).value;
await this.statusService.updateNoteStatus(newStatus);
// Dispatch event for UI update
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { status: newStatus }
}));
});
// Add hide button
if (!this.dropdownContainer) return;
const hideButton = this.dropdownContainer.createEl('button', {
text: 'Hide Bar',
cls: 'note-status-hide-button clickable-icon mod-cta'
});
hideButton.addEventListener('click', () => {
this.settings.showStatusDropdown = false;
this.render();
// Trigger settings save
window.dispatchEvent(new CustomEvent('note-status:settings-changed'));
new Notice('Status dropdown hidden');
});
}
/**
* Show status dropdown in context menu
*/
public showInContextMenu(editor: Editor, view: MarkdownView): void {
const menu = new Menu();
// Add status options to menu
this.settings.customStatuses
.filter(status => status.name !== 'unknown')
.forEach(status => {
menu.addItem((item) =>
item
.setTitle(`${status.name} ${status.icon}`)
.setIcon('tag')
.onClick(async () => {
await this.statusService.updateNoteStatus(status.name);
// Dispatch event for UI update
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: { status: status.name }
}));
})
);
});
// Position menu near cursor
const cursor = editor.getCursor('to');
editor.posToOffset(cursor);
const editorEl = view.contentEl.querySelector('.cm-content');
if (editorEl) {
const rect = editorEl.getBoundingClientRect();
menu.showAtPosition({ x: rect.left, y: rect.bottom });
} else {
menu.showAtPosition({ x: 0, y: 0 });
}
}
/**
* Remove dropdown when plugin is unloaded
*/
public unload(): void {
if (this.dropdownContainer) {
this.dropdownContainer.remove();
this.dropdownContainer = undefined;
}
}
}

289
ui/status-pane-view.ts Normal file
View file

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

119
utils/dom-utils.ts Normal file
View file

@ -0,0 +1,119 @@
/**
* Utility functions for DOM manipulation
*/
/**
* Creates a button element with given properties
*/
export function createButton(
container: HTMLElement,
text: string,
cls: string,
clickHandler: (e: MouseEvent) => void,
icon?: string
): HTMLButtonElement {
const button = container.createEl('button', {
text: text,
cls: cls
});
if (icon) {
button.innerHTML = icon;
}
button.addEventListener('click', clickHandler);
return button;
}
/**
* Creates a text input with label
*/
export function createLabeledInput(
container: HTMLElement,
id: string,
labelText: string,
placeholder: string,
initialValue: string,
changeHandler: (value: string) => void
): HTMLInputElement {
const wrapper = container.createDiv({ cls: 'setting-item' });
wrapper.createEl('label', {
text: labelText,
attr: { for: id }
});
const input = wrapper.createEl('input', {
type: 'text',
placeholder: placeholder,
value: initialValue,
attr: { id: id }
});
input.addEventListener('change', () => {
changeHandler(input.value);
});
return input;
}
/**
* Creates a dropdown select element
*/
export function createSelect(
container: HTMLElement,
options: { value: string; text: string; selected?: boolean }[],
changeHandler: (value: string) => void,
cls?: string
): HTMLSelectElement {
const select = container.createEl('select', { cls: cls });
options.forEach(option => {
const optionEl = select.createEl('option', {
text: option.text,
value: option.value
});
if (option.selected) {
optionEl.selected = true;
}
});
select.addEventListener('change', (e) => {
changeHandler((e.target as HTMLSelectElement).value);
});
return select;
}
/**
* Adds a toggle switch with label
*/
export function createToggle(
container: HTMLElement,
labelText: string,
initialValue: boolean,
changeHandler: (value: boolean) => void
): HTMLElement {
const wrapper = container.createDiv({ cls: 'setting-item-toggle' });
// Create label
wrapper.createEl('span', { text: labelText, cls: 'setting-item-label' });
// Create toggle component
const toggleContainer = wrapper.createDiv({ cls: 'setting-item-control' });
const toggle = toggleContainer.createEl('div', { cls: 'checkbox-container' });
// Create the actual input
const input = toggle.createEl('input', {
type: 'checkbox',
cls: 'checkbox'
});
input.checked = initialValue;
input.addEventListener('change', () => {
changeHandler(input.checked);
});
return wrapper;
}

104
utils/file-utils.ts Normal file
View file

@ -0,0 +1,104 @@
import { App, TFile } from 'obsidian';
/**
* Utility functions for file operations
*/
/**
* Checks if a file is a markdown file
*/
export function isMarkdownFile(file: TFile): boolean {
return file && file.extension === 'md';
}
/**
* Gets the frontmatter from file content
*/
export function extractFrontmatter(content: string): {
hasFrontMatter: boolean;
frontMatter?: string;
content: string;
} {
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?/);
if (frontmatterMatch) {
return {
hasFrontMatter: true,
frontMatter: frontmatterMatch[1],
content: content.slice(frontmatterMatch[0].length)
};
}
return {
hasFrontMatter: false,
content
};
}
/**
* Updates a property in frontmatter
*/
export function updateFrontmatterProperty(
content: string,
property: string,
value: string
): string {
const { hasFrontMatter, frontMatter, content: mainContent } = extractFrontmatter(content);
if (hasFrontMatter && frontMatter) {
// Check if property already exists in frontmatter
if (frontMatter.includes(`${property}:`)) {
// Replace existing property
const updatedFrontMatter = frontMatter.replace(
new RegExp(`${property}:\\s*[^\\n]+`, 'm'),
`${property}: ${value}`
);
return `---\n${updatedFrontMatter}\n---\n${mainContent}`;
} else {
// Add new property to existing frontmatter
return `---\n${frontMatter}\n${property}: ${value}\n---\n${mainContent}`;
}
} else {
// Create new frontmatter
return `---\n${property}: ${value}\n---\n\n${content}`;
}
}
/**
* Reads frontmatter property from file
*/
export async function getFrontmatterProperty(
app: App,
file: TFile,
property: string
): Promise<string | null> {
const cache = app.metadataCache.getFileCache(file);
if (cache?.frontmatter && property in cache.frontmatter) {
return cache.frontmatter[property];
}
return null;
}
/**
* Safely updates a file's frontmatter
*/
export async function updateFileFrontmatter(
app: App,
file: TFile,
property: string,
value: string
): Promise<void> {
if (!isMarkdownFile(file)) {
return;
}
const content = await app.vault.read(file);
const newContent = updateFrontmatterProperty(content, property, value);
// Only write if content has changed
if (newContent !== content) {
await app.vault.modify(file, newContent);
}
}