devonthesofa_obsidian-note-.../integrations/explorer/explorer-integration.ts
2025-05-24 19:10:20 +02:00

350 lines
10 KiB
TypeScript

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