refactor: format and build

This commit is contained in:
Aleix Soler 2025-05-25 13:22:53 +02:00
parent 73765169fb
commit 028aed1f2b
15 changed files with 2789 additions and 2234 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,24 @@
import { NoteStatusSettings, Status } from "../../models/types";
/**
* Callbacks interface for settings UI interactions
*/
export interface SettingsUICallbacks {
/** Handle template enable/disable toggle */
onTemplateToggle: (templateId: string, enabled: boolean) => Promise<void>;
/** Handle general setting changes */
onSettingChange: (
key: keyof NoteStatusSettings,
value: boolean | string | string[],
) => Promise<void>;
/** Handle custom status field changes */
onCustomStatusChange: (
index: number,
field: keyof Status,
value: string,
) => Promise<void>;
/** Handle custom status removal */
onCustomStatusRemove: (index: number) => Promise<void>;
/** Handle adding new custom status */
onCustomStatusAdd: () => Promise<void>;
}

View file

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

View file

@ -1,44 +1,76 @@
import { TFile, View } from 'obsidian';
import { TFile, View } from "obsidian";
declare module "obsidian" {
interface WorkspaceLeaf {
// TODO: Is deprecated, fix me
id: string;
}
interface Commands {
removeCommand(id: string): void;
}
interface App {
clipboard?: unknown;
internalPlugins: InternalPlugins;
commands: Commands;
}
interface GlobalSearchPlugin {
openGlobalSearch(query: string): void;
}
interface InternalPlugins {
getPluginById(
id: "global-search",
): { instance: GlobalSearchPlugin } | undefined;
}
}
/**
* Defines a status with name, icon and color
*/
export interface Status {
name: string;
icon: string;
color?: string; // Optional color property
description?: string; // Optional description property
name: string;
icon: string;
color?: string; // Optional color property
description?: string; // Optional description property
[key: string]: unknown;
}
/**
* Plugin settings interface
*/
export interface NoteStatusSettings {
statusColors: Record<string, string>;
showStatusBar: boolean;
autoHideStatusBar: boolean;
customStatuses: Status[];
showStatusIconsInExplorer: boolean;
hideUnknownStatusInExplorer: boolean;
collapsedStatuses: Record<string, boolean>;
compactView: boolean;
enabledTemplates: string[]; // IDs of enabled templates
useCustomStatusesOnly: boolean; // Whether to use only custom statuses or include templates
useMultipleStatuses: boolean; // Whether to allow multiple statuses per note
tagPrefix: string; // Prefix for the status tag (default: 'status')
strictStatuses: boolean; // Whether to only show known statuses
excludeUnknownStatus: boolean; // Whether to exclude files with unknown status from the status pane
quickStatusCommands: string[];
statusColors: Record<string, string>;
showStatusBar: boolean;
autoHideStatusBar: boolean;
customStatuses: Status[];
showStatusIconsInExplorer: boolean;
hideUnknownStatusInExplorer: boolean;
collapsedStatuses: Record<string, boolean>;
compactView: boolean;
enabledTemplates: string[]; // IDs of enabled templates
useCustomStatusesOnly: boolean; // Whether to use only custom statuses or include templates
useMultipleStatuses: boolean; // Whether to allow multiple statuses per note
tagPrefix: string; // Prefix for the status tag (default: 'status')
strictStatuses: boolean; // Whether to only show known statuses
excludeUnknownStatus: boolean; // Whether to exclude files with unknown status from the status pane
quickStatusCommands: string[];
[key: string]: unknown;
}
/**
* Extended interface for file explorer view
*/
export interface FileExplorerView extends View {
fileItems: Record<string, {
el?: HTMLElement;
file?: TFile;
titleEl?: HTMLElement;
selfEl?: HTMLElement;
}>;
fileItems: Record<
string,
{
el?: HTMLElement;
file?: TFile;
titleEl?: HTMLElement;
selfEl?: HTMLElement;
}
>;
}

View file

@ -1,388 +1,434 @@
import { App, TFile, Editor, Notice } from 'obsidian';
import { NoteStatusSettings, Status } from '../models/types';
import { PREDEFINED_TEMPLATES } from '../constants/status-templates';
import { App, TFile, Editor, Notice } from "obsidian";
import { NoteStatusSettings, Status } from "../models/types";
import { PREDEFINED_TEMPLATES } from "../constants/status-templates";
/**
* Service for handling note status operations
*/
export class StatusService {
private app: App;
private settings: NoteStatusSettings;
private allStatuses: Status[] = [];
private app: App;
private settings: NoteStatusSettings;
private allStatuses: Status[] = [];
constructor(app: App, settings: NoteStatusSettings) {
this.app = app;
this.settings = settings;
this.updateAllStatuses();
}
constructor(app: App, settings: NoteStatusSettings) {
this.app = app;
this.settings = settings;
this.updateAllStatuses();
}
/**
* Updates the settings reference and recalculates all statuses
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.updateAllStatuses();
}
/**
* Updates the settings reference and recalculates all statuses
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.updateAllStatuses();
}
/**
* Updates the combined list of all statuses (from templates and custom)
*/
private updateAllStatuses(): void {
// Start with custom statuses
this.allStatuses = [...this.settings.customStatuses];
/**
* Updates the combined list of all statuses (from templates and custom)
*/
private updateAllStatuses(): void {
// Start with custom statuses
this.allStatuses = [...this.settings.customStatuses];
// Add statuses from enabled templates if not using custom only
if (!this.settings.useCustomStatusesOnly) {
const templateStatuses = this.getTemplateStatuses();
for (const status of templateStatuses) {
const existingIndex = this.allStatuses.findIndex(s =>
s.name.toLowerCase() === status.name.toLowerCase());
if (existingIndex === -1) {
// Add new status
this.allStatuses.push(status);
} else if (status.color) {
// Update color in settings if it doesn't exist
if (!this.settings.statusColors[status.name]) {
this.settings.statusColors[status.name] = status.color;
}
}
}
}
}
// Add statuses from enabled templates if not using custom only
if (!this.settings.useCustomStatusesOnly) {
const templateStatuses = this.getTemplateStatuses();
/**
* Gets all statuses from enabled templates
*/
public getTemplateStatuses(): Status[] {
return this.settings.enabledTemplates
.map(id => PREDEFINED_TEMPLATES.find(t => t.id === id))
.filter(Boolean)
.flatMap(template => template ? template.statuses : []);
}
for (const status of templateStatuses) {
const existingIndex = this.allStatuses.findIndex(
(s) => s.name.toLowerCase() === status.name.toLowerCase(),
);
/**
* Get all available statuses (combined from templates and custom)
*/
public getAllStatuses(): Status[] {
return this.allStatuses;
}
if (existingIndex === -1) {
// Add new status
this.allStatuses.push(status);
} else if (status.color) {
// Update color in settings if it doesn't exist
if (!this.settings.statusColors[status.name]) {
this.settings.statusColors[status.name] = status.color;
}
}
}
}
}
/**
* Get the statuses of a file from its metadata
*/
public getFileStatuses(file: TFile): string[] {
const cachedMetadata = this.app.metadataCache.getFileCache(file);
if (!cachedMetadata?.frontmatter) return ['unknown'];
const frontmatterStatus = cachedMetadata.frontmatter[this.settings.tagPrefix];
if (!frontmatterStatus) return ['unknown'];
const statuses: string[] = [];
if (Array.isArray(frontmatterStatus)) {
for (const statusName of frontmatterStatus) {
if (this.settings.strictStatuses) {
this.addValidStatus(statusName.toString(), statuses);
} else {
const cleanStatus = statusName.toString().trim();
if (cleanStatus) statuses.push(cleanStatus);
}
}
} else {
if (this.settings.strictStatuses) {
this.addValidStatus(frontmatterStatus.toString(), statuses);
} else {
const cleanStatus = frontmatterStatus.toString().trim();
if (cleanStatus) statuses.push(cleanStatus);
}
}
return statuses.length > 0 ? statuses : ['unknown'];
}
/**
* Add a status to the list if it's valid
*/
private addValidStatus(statusName: string, targetStatuses: string[]): void {
const normalizedStatus = statusName.toLowerCase();
const matchingStatus = this.allStatuses.find(s =>
s.name.toLowerCase() === normalizedStatus);
if (matchingStatus) {
targetStatuses.push(matchingStatus.name);
}
}
/**
* Gets all statuses from enabled templates
*/
public getTemplateStatuses(): Status[] {
return this.settings.enabledTemplates
.map((id) => PREDEFINED_TEMPLATES.find((t) => t.id === id))
.filter(Boolean)
.flatMap((template) => (template ? template.statuses : []));
}
/**
* Get the icon for a given status
*/
public getStatusIcon(status: string): string {
const customStatus = this.allStatuses.find(
s => s.name.toLowerCase() === status.toLowerCase()
);
return customStatus ? customStatus.icon : '❓';
}
/**
* Get all available statuses (combined from templates and custom)
*/
public getAllStatuses(): Status[] {
return this.allStatuses;
}
/**
* Insert status metadata in the editor
*/
public insertStatusMetadataInEditor(editor: Editor): void {
const content = editor.getValue();
const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/);
// Check if status metadata already exists
const statusTagRegex = new RegExp(`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`, 'm');
if (frontMatterMatch) {
const frontMatter = frontMatterMatch[1];
if (frontMatter.match(statusTagRegex)) {
// Status already exists, do nothing
return;
}
// Add to existing frontmatter
const defaultStatuses = ['unknown'];
const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
this.insertIntoExistingFrontmatter(editor, content, frontMatterMatch, statusMetadata);
} else {
// No frontmatter, check if status exists in content somehow
if (content.match(statusTagRegex)) {
return;
}
// Create new frontmatter
const defaultStatuses = ['unknown'];
const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
this.createFrontmatterWithStatus(editor, content, statusMetadata);
}
}
/**
* Insert status metadata into existing frontmatter
*/
private insertIntoExistingFrontmatter(
editor: Editor,
content: string,
frontMatterMatch: RegExpMatchArray,
statusMetadata: string
): void {
const frontMatter = frontMatterMatch[1];
const statusTagRegex = new RegExp(`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`, 'm');
const updatedFrontMatter = frontMatter.match(statusTagRegex)
? frontMatter.replace(statusTagRegex, statusMetadata)
: `${frontMatter}\n${statusMetadata}`;
const updatedContent = content.replace(/^---\n([\s\S]+?)\n---/, `---\n${updatedFrontMatter}\n---`);
editor.setValue(updatedContent);
}
/**
* Create new frontmatter with status metadata
*/
private createFrontmatterWithStatus(editor: Editor, content: string, statusMetadata: string): void {
const newFrontMatter = `---\n${statusMetadata}\n---\n\n${content.trim()}`;
editor.setValue(newFrontMatter);
}
/**
* Get all markdown files with optional filtering
*/
public getMarkdownFiles(searchQuery = ''): TFile[] {
const files = this.app.vault.getMarkdownFiles();
if (!searchQuery) return files;
const lowerQuery = searchQuery.toLowerCase();
return files.filter(file =>
file.basename.toLowerCase().includes(lowerQuery));
}
/**
* Group files by their status
*/
public groupFilesByStatus(searchQuery = ''): Record<string, TFile[]> {
const statusGroups: Record<string, TFile[]> = {};
// Initialize groups for all statuses
for (const status of this.allStatuses) {
statusGroups[status.name] = [];
}
// Ensure 'unknown' status exists
statusGroups['unknown'] = statusGroups['unknown'] || [];
// Get and process all files matching the search query
const files = this.getMarkdownFiles(searchQuery);
for (const file of files) {
const statuses = this.getFileStatuses(file);
for (const status of statuses) {
if (statusGroups[status]) {
statusGroups[status].push(file);
}
}
}
return statusGroups;
}
/**
* Centralizes all status modification operations
*/
private async modifyNoteStatus(options: {
files: TFile | TFile[];
statuses: string | string[];
operation: 'set' | 'add' | 'remove' | 'toggle';
showNotice?: boolean;
}): Promise<void> {
const { operation, showNotice = true } = options;
const targetFiles = Array.isArray(options.files) ? options.files : [options.files];
const targetStatuses = Array.isArray(options.statuses) ? options.statuses : [options.statuses];
if (targetFiles.length === 0) {
if (showNotice) new Notice('No files selected');
return;
}
// Process each file
const updatePromises = targetFiles.map(async (file) => {
if (!file || file.extension !== 'md') return;
// Get current statuses for the file
const currentStatuses = this.getFileStatuses(file);
let newStatuses: string[] = [];
switch (operation) {
case 'set':
// Replace all statuses with the new ones
newStatuses = [...targetStatuses];
break;
case 'add':
// Add new statuses without duplicates
newStatuses = [...new Set([
...currentStatuses.filter(s => s !== 'unknown'),
...targetStatuses
])];
break;
case 'remove':
// Remove specified statuses
newStatuses = currentStatuses.filter(
status => !targetStatuses.includes(status)
);
break;
case 'toggle':
// Toggle each status (add if not present, remove if present)
newStatuses = [...currentStatuses];
for (const status of targetStatuses) {
if (currentStatuses.includes(status)) {
newStatuses = newStatuses.filter(s => s !== status);
} else {
newStatuses = [...newStatuses.filter(s => s !== 'unknown'), status];
}
}
break;
}
// Handle empty result (should revert to unknown)
if (newStatuses.length === 0) {
newStatuses = ['unknown'];
}
// Apply updates to frontmatter
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
frontmatter[this.settings.tagPrefix] = newStatuses;
});
this.notifyStatusChanged(newStatuses, file)
});
await Promise.all(updatePromises);
// Show notification for batch operations
if (showNotice && targetFiles.length > 1) {
const statusText = targetStatuses.length === 1
? targetStatuses[0]
: `${targetStatuses.length} statuses`;
const operationText = operation === 'set' ? 'updated' :
operation === 'add' ? 'added to' :
operation === 'remove' ? 'removed from' : 'toggled on';
new Notice(`${statusText} ${operationText} ${targetFiles.length} files`);
}
}
/**
* Handles UI-triggered status changes with appropriate logic based on context
* This is the central function that all UI components should call for status changes
*/
public async handleStatusChange(options: {
files: TFile | TFile[];
statuses: string | string[];
isMultipleSelection?: boolean;
allowMultipleStatuses?: boolean;
operation?: 'set' | 'add' | 'remove' | 'toggle';
showNotice?: boolean;
afterChange?: (updatedStatuses: string[]) => void;
}): Promise<void> {
const {
files,
statuses,
isMultipleSelection = false,
allowMultipleStatuses = this.settings.useMultipleStatuses,
operation: explicitOperation,
showNotice = isMultipleSelection,
afterChange
} = options;
const targetFiles = Array.isArray(files) ? files : [files];
const targetStatuses = Array.isArray(statuses) ? statuses : [statuses];
// Determine operation based on context if not explicitly specified
let operation: 'set' | 'add' | 'remove' | 'toggle';
if (explicitOperation) {
operation = explicitOperation;
} else if (isMultipleSelection) {
// For multiple files, we need to check if we should add or remove
const firstStatus = targetStatuses[0]; // Use first status for multi-file operations
const filesWithStatus = targetFiles.filter(file =>
this.getFileStatuses(file).includes(firstStatus)
);
operation = filesWithStatus.length > targetFiles.length / 2 ? 'remove' : 'add';
} else {
// For single file operations
if (allowMultipleStatuses) {
operation = 'toggle';
} else {
operation = 'set';
}
}
// Apply the changes
await this.modifyNoteStatus({
files: targetFiles,
statuses: targetStatuses,
operation,
showNotice
});
}
/**
* Dispatch status changed event
*/
private notifyStatusChanged(statuses: string[], file?: TFile): void {
// Dispatch the specific status change event
window.dispatchEvent(new CustomEvent('note-status:status-changed', {
detail: {
statuses,
file: file?.path
}
}));
}
/**
* Get the statuses of a file from its metadata
*/
public getFileStatuses(file: TFile): string[] {
const cachedMetadata = this.app.metadataCache.getFileCache(file);
if (!cachedMetadata?.frontmatter) return ["unknown"];
const frontmatterStatus =
cachedMetadata.frontmatter[this.settings.tagPrefix];
if (!frontmatterStatus) return ["unknown"];
const statuses: string[] = [];
if (Array.isArray(frontmatterStatus)) {
for (const statusName of frontmatterStatus) {
if (this.settings.strictStatuses) {
this.addValidStatus(statusName.toString(), statuses);
} else {
const cleanStatus = statusName.toString().trim();
if (cleanStatus) statuses.push(cleanStatus);
}
}
} else {
if (this.settings.strictStatuses) {
this.addValidStatus(frontmatterStatus.toString(), statuses);
} else {
const cleanStatus = frontmatterStatus.toString().trim();
if (cleanStatus) statuses.push(cleanStatus);
}
}
return statuses.length > 0 ? statuses : ["unknown"];
}
/**
* Add a status to the list if it's valid
*/
private addValidStatus(statusName: string, targetStatuses: string[]): void {
const normalizedStatus = statusName.toLowerCase();
const matchingStatus = this.allStatuses.find(
(s) => s.name.toLowerCase() === normalizedStatus,
);
if (matchingStatus) {
targetStatuses.push(matchingStatus.name);
}
}
/**
* Get the icon for a given status
*/
public getStatusIcon(status: string): string {
const customStatus = this.allStatuses.find(
(s) => s.name.toLowerCase() === status.toLowerCase(),
);
return customStatus ? customStatus.icon : "❓";
}
/**
* Insert status metadata in the editor
*/
public insertStatusMetadataInEditor(editor: Editor): void {
const content = editor.getValue();
const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/);
// Check if status metadata already exists
const statusTagRegex = new RegExp(
`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`,
"m",
);
if (frontMatterMatch) {
const frontMatter = frontMatterMatch[1];
if (frontMatter.match(statusTagRegex)) {
// Status already exists, do nothing
return;
}
// Add to existing frontmatter
const defaultStatuses = ["unknown"];
const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
this.insertIntoExistingFrontmatter(
editor,
content,
frontMatterMatch,
statusMetadata,
);
} else {
// No frontmatter, check if status exists in content somehow
if (content.match(statusTagRegex)) {
return;
}
// Create new frontmatter
const defaultStatuses = ["unknown"];
const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
this.createFrontmatterWithStatus(editor, content, statusMetadata);
}
}
/**
* Insert status metadata into existing frontmatter
*/
private insertIntoExistingFrontmatter(
editor: Editor,
content: string,
frontMatterMatch: RegExpMatchArray,
statusMetadata: string,
): void {
const frontMatter = frontMatterMatch[1];
const statusTagRegex = new RegExp(
`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`,
"m",
);
const updatedFrontMatter = frontMatter.match(statusTagRegex)
? frontMatter.replace(statusTagRegex, statusMetadata)
: `${frontMatter}\n${statusMetadata}`;
const updatedContent = content.replace(
/^---\n([\s\S]+?)\n---/,
`---\n${updatedFrontMatter}\n---`,
);
editor.setValue(updatedContent);
}
/**
* Create new frontmatter with status metadata
*/
private createFrontmatterWithStatus(
editor: Editor,
content: string,
statusMetadata: string,
): void {
const newFrontMatter = `---\n${statusMetadata}\n---\n\n${content.trim()}`;
editor.setValue(newFrontMatter);
}
/**
* Get all markdown files with optional filtering
*/
public getMarkdownFiles(searchQuery = ""): TFile[] {
const files = this.app.vault.getMarkdownFiles();
if (!searchQuery) return files;
const lowerQuery = searchQuery.toLowerCase();
return files.filter((file) =>
file.basename.toLowerCase().includes(lowerQuery),
);
}
/**
* Group files by their status
*/
public groupFilesByStatus(searchQuery = ""): Record<string, TFile[]> {
const statusGroups: Record<string, TFile[]> = {};
// Initialize groups for all statuses
for (const status of this.allStatuses) {
statusGroups[status.name] = [];
}
// Ensure 'unknown' status exists
statusGroups["unknown"] = statusGroups["unknown"] || [];
// Get and process all files matching the search query
const files = this.getMarkdownFiles(searchQuery);
for (const file of files) {
const statuses = this.getFileStatuses(file);
for (const status of statuses) {
if (statusGroups[status]) {
statusGroups[status].push(file);
}
}
}
return statusGroups;
}
/**
* Centralizes all status modification operations
*/
private async modifyNoteStatus(options: {
files: TFile | TFile[];
statuses: string | string[];
operation: "set" | "add" | "remove" | "toggle";
showNotice?: boolean;
}): Promise<void> {
const { operation, showNotice = true } = options;
const targetFiles = Array.isArray(options.files)
? options.files
: [options.files];
const targetStatuses = Array.isArray(options.statuses)
? options.statuses
: [options.statuses];
if (targetFiles.length === 0) {
if (showNotice) new Notice("No files selected");
return;
}
// Process each file
const updatePromises = targetFiles.map(async (file) => {
if (!file || file.extension !== "md") return;
// Get current statuses for the file
const currentStatuses = this.getFileStatuses(file);
let newStatuses: string[] = [];
switch (operation) {
case "set":
// Replace all statuses with the new ones
newStatuses = [...targetStatuses];
break;
case "add":
// Add new statuses without duplicates
newStatuses = [
...new Set([
...currentStatuses.filter((s) => s !== "unknown"),
...targetStatuses,
]),
];
break;
case "remove":
// Remove specified statuses
newStatuses = currentStatuses.filter(
(status) => !targetStatuses.includes(status),
);
break;
case "toggle":
// Toggle each status (add if not present, remove if present)
newStatuses = [...currentStatuses];
for (const status of targetStatuses) {
if (currentStatuses.includes(status)) {
newStatuses = newStatuses.filter(
(s) => s !== status,
);
} else {
newStatuses = [
...newStatuses.filter((s) => s !== "unknown"),
status,
];
}
}
break;
}
// Handle empty result (should revert to unknown)
if (newStatuses.length === 0) {
newStatuses = ["unknown"];
}
// Apply updates to frontmatter
await this.app.fileManager.processFrontMatter(
file,
(frontmatter) => {
frontmatter[this.settings.tagPrefix] = newStatuses;
},
);
this.notifyStatusChanged(newStatuses, file);
});
await Promise.all(updatePromises);
// Show notification for batch operations
if (showNotice && targetFiles.length > 1) {
const statusText =
targetStatuses.length === 1
? targetStatuses[0]
: `${targetStatuses.length} statuses`;
const operationText =
operation === "set"
? "updated"
: operation === "add"
? "added to"
: operation === "remove"
? "removed from"
: "toggled on";
new Notice(
`${statusText} ${operationText} ${targetFiles.length} files`,
);
}
}
/**
* Handles UI-triggered status changes with appropriate logic based on context
* This is the central function that all UI components should call for status changes
*/
public async handleStatusChange(options: {
files: TFile | TFile[];
statuses: string | string[];
isMultipleSelection?: boolean;
allowMultipleStatuses?: boolean;
operation?: "set" | "add" | "remove" | "toggle";
showNotice?: boolean;
afterChange?: (updatedStatuses: string[]) => void;
}): Promise<void> {
const {
files,
statuses,
isMultipleSelection = false,
allowMultipleStatuses = this.settings.useMultipleStatuses,
operation: explicitOperation,
showNotice = isMultipleSelection,
} = options;
const targetFiles = Array.isArray(files) ? files : [files];
const targetStatuses = Array.isArray(statuses) ? statuses : [statuses];
// Determine operation based on context if not explicitly specified
let operation: "set" | "add" | "remove" | "toggle";
if (explicitOperation) {
operation = explicitOperation;
} else if (isMultipleSelection) {
// For multiple files, we need to check if we should add or remove
const firstStatus = targetStatuses[0]; // Use first status for multi-file operations
const filesWithStatus = targetFiles.filter((file) =>
this.getFileStatuses(file).includes(firstStatus),
);
operation =
filesWithStatus.length > targetFiles.length / 2
? "remove"
: "add";
} else {
// For single file operations
if (allowMultipleStatuses) {
operation = "toggle";
} else {
operation = "set";
}
}
// Apply the changes
await this.modifyNoteStatus({
files: targetFiles,
statuses: targetStatuses,
operation,
showNotice,
});
}
/**
* Dispatch status changed event
*/
private notifyStatusChanged(statuses: string[], file?: TFile): void {
// Dispatch the specific status change event
window.dispatchEvent(
new CustomEvent("note-status:status-changed", {
detail: {
statuses,
file: file?.path,
},
}),
);
}
}

View file

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

View file

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