diff --git a/src/common/setting-definition.ts b/src/common/setting-definition.ts index ffb4c641..33785cb3 100644 --- a/src/common/setting-definition.ts +++ b/src/common/setting-definition.ts @@ -512,7 +512,8 @@ export interface ProjectNamingStrategy { /** Custom project definition for V2 */ export interface CustomProject { id: string; - name: string; + name: string; // Internal name with dashes for metadata + displayName?: string; // Original name with spaces for display color: string; createdAt: number; updatedAt: number; diff --git a/src/components/ui/inputs/AutoComplete.ts b/src/components/ui/inputs/AutoComplete.ts index 20c06dac..e2d126cc 100644 --- a/src/components/ui/inputs/AutoComplete.ts +++ b/src/components/ui/inputs/AutoComplete.ts @@ -20,9 +20,16 @@ let globalCache: GlobalAutoCompleteCache | null = null; const CACHE_DURATION = 30000; // 30 seconds // Helper function to get cached data -async function getCachedData(plugin: TaskProgressBarPlugin): Promise { +async function getCachedData( + plugin: TaskProgressBarPlugin, + forceRefresh: boolean = false +): Promise { const now = Date.now(); + if (forceRefresh) { + globalCache = null; + } + if (!globalCache || now - globalCache.lastUpdate > CACHE_DURATION) { // Fetch fresh data const tags = Object.keys(plugin.app.metadataCache.getTags() || {}).map( @@ -32,7 +39,7 @@ async function getCachedData(plugin: TaskProgressBarPlugin): Promise p.name) + .filter(Boolean); + projects.push(...custom); + // Path mappings + const mapped = (cfg.pathMappings || []) + .filter((m) => (m as any).enabled !== false) + .map((m) => m.projectName) + .filter(Boolean); + projects.push(...mapped); + } + } catch (e) { + console.warn("Failed to merge settings-defined projects:", e); + } + + // Deduplicate and sort + const uniq = (arr: string[]) => + Array.from(new Set(arr.filter(Boolean))); + projects = uniq(projects).sort(); + contexts = uniq(contexts).sort(); + globalCache = { tags, projects, @@ -128,11 +164,24 @@ export class ProjectSuggest extends CustomSuggest { ) { // Initialize with empty list, will be populated asynchronously super(app, inputEl, []); - - // Load cached data asynchronously - getCachedData(plugin).then(cachedData => { + + // Load fresh data immediately so newly added projects appear + getCachedData(plugin, true).then((cachedData) => { this.availableChoices = cachedData.projects; }); + + // Refresh on focus to pick up recent changes (settings/index updates) + inputEl.addEventListener("focus", async () => { + try { + const data = await getCachedData(plugin, true); + this.availableChoices = data.projects; + } catch (e) { + console.warn( + "ProjectSuggest: failed to refresh projects on focus", + e + ); + } + }); } } @@ -147,9 +196,9 @@ export class ContextSuggest extends CustomSuggest { ) { // Initialize with empty list, will be populated asynchronously super(app, inputEl, []); - + // Load cached data asynchronously - getCachedData(plugin).then(cachedData => { + getCachedData(plugin).then((cachedData) => { this.availableChoices = cachedData.contexts; }); } @@ -166,9 +215,9 @@ export class TagSuggest extends CustomSuggest { ) { // Initialize with empty list, will be populated asynchronously super(app, inputEl, []); - + // Load cached data asynchronously - getCachedData(plugin).then(cachedData => { + getCachedData(plugin).then((cachedData) => { this.availableChoices = cachedData.tags; }); } diff --git a/src/experimental/v2/components/ProjectList.ts b/src/experimental/v2/components/ProjectList.ts index 539c3b76..d5a9b0f3 100644 --- a/src/experimental/v2/components/ProjectList.ts +++ b/src/experimental/v2/components/ProjectList.ts @@ -1,13 +1,14 @@ -import { Component, Platform, setIcon, Menu } from "obsidian"; +import { Component, Platform, setIcon, Menu, Modal, App } from "obsidian"; import TaskProgressBarPlugin from "../../../index"; import { Task } from "../../../types/task"; -import { ProjectPopover, ProjectModal } from "./ProjectPopover"; +import { ProjectPopover, ProjectModal, EditProjectModal } from "./ProjectPopover"; import type { CustomProject } from "../../../common/setting-definition"; import { t } from "@/translations/helper"; interface Project { id: string; name: string; + displayName?: string; color: string; taskCount: number; createdAt?: number; @@ -82,9 +83,12 @@ export class ProjectList extends Component { const projectName = task.metadata?.project; if (projectName) { if (!projectMap.has(projectName)) { + // Convert dashes back to spaces for display + const displayName = projectName.replace(/-/g, ' '); projectMap.set(projectName, { id: projectName, name: projectName, + displayName: displayName, color: this.generateColorForProject(projectName), taskCount: 0, }); @@ -136,9 +140,9 @@ export class ProjectList extends Component { this.projects.sort((a, b) => { switch (this.currentSort) { case "name-asc": - return this.collator.compare(a.name, b.name); + return this.collator.compare(a.displayName || a.name, b.displayName || b.name); case "name-desc": - return this.collator.compare(b.name, a.name); + return this.collator.compare(b.displayName || b.name, a.displayName || a.name); case "tasks-asc": return a.taskCount - b.taskCount; case "tasks-desc": @@ -198,7 +202,7 @@ export class ProjectList extends Component { const projectName = projectItem.createSpan({ cls: "v2-project-name", - text: project.name, + text: project.displayName || project.name, }); const projectCount = projectItem.createSpan({ @@ -210,6 +214,12 @@ export class ProjectList extends Component { this.setActiveProject(project.id); this.onProjectSelect(project.id); }); + + // Add context menu handler + this.registerDomEvent(projectItem, "contextmenu", (e: MouseEvent) => { + e.preventDefault(); + this.showProjectContextMenu(e, project); + }); }); // Add new project button @@ -350,6 +360,7 @@ export class ProjectList extends Component { this.projects.push({ id: customProject.id, name: customProject.name, + displayName: customProject.displayName || customProject.name, color: customProject.color, taskCount: 0, // Will be updated by task counting createdAt: customProject.createdAt, @@ -359,6 +370,7 @@ export class ProjectList extends Component { // Update existing project with custom color this.projects[existingIndex].id = customProject.id; this.projects[existingIndex].color = customProject.color; + this.projects[existingIndex].displayName = customProject.displayName || customProject.name; this.projects[existingIndex].createdAt = customProject.createdAt; this.projects[existingIndex].updatedAt = @@ -425,4 +437,190 @@ export class ProjectList extends Component { }) ); } + + private showProjectContextMenu(event: MouseEvent, project: Project) { + const menu = new Menu(); + + // Check if this is a custom project + const isCustomProject = this.plugin.settings.projectConfig?.customProjects?.some( + cp => cp.id === project.id || cp.name === project.name + ); + + // Edit Project option + menu.addItem((item) => { + item + .setTitle(t("Edit Project")) + .setIcon("edit"); + + if (isCustomProject) { + item.onClick(() => { + this.editProject(project); + }); + } else { + item.setDisabled(true); + } + }); + + // Delete Project option + menu.addItem((item) => { + item + .setTitle(t("Delete Project")) + .setIcon("trash"); + + if (isCustomProject) { + item.onClick(() => { + this.deleteProject(project); + }); + } else { + item.setDisabled(true); + } + }); + + menu.showAtMouseEvent(event); + } + + private editProject(project: Project) { + // Find the custom project data + let customProject = this.plugin.settings.projectConfig?.customProjects?.find( + cp => cp.id === project.id || cp.name === project.name + ); + + if (!customProject) { + // Create a new custom project entry if it doesn't exist + customProject = { + id: project.id, + name: project.name, + displayName: project.displayName || project.name, + color: project.color, + createdAt: Date.now(), + updatedAt: Date.now() + }; + } + + // Open edit modal + const modal = new EditProjectModal( + this.plugin.app, + this.plugin, + customProject, + async (updatedProject) => { + await this.updateProject(updatedProject); + } + ); + modal.open(); + } + + private async updateProject(updatedProject: CustomProject) { + // Initialize if needed + if (!this.plugin.settings.projectConfig) { + this.plugin.settings.projectConfig = { + enableEnhancedProject: false, + pathMappings: [], + metadataConfig: { + metadataKey: "project", + enabled: false, + }, + configFile: { + fileName: "project.md", + searchRecursively: true, + enabled: false, + }, + metadataMappings: [], + defaultProjectNaming: { + strategy: "filename", + stripExtension: true, + enabled: false, + }, + customProjects: [], + }; + } + + if (!this.plugin.settings.projectConfig.customProjects) { + this.plugin.settings.projectConfig.customProjects = []; + } + + // Find and update the project + const index = this.plugin.settings.projectConfig.customProjects.findIndex( + cp => cp.id === updatedProject.id + ); + + if (index !== -1) { + this.plugin.settings.projectConfig.customProjects[index] = updatedProject; + } else { + this.plugin.settings.projectConfig.customProjects.push(updatedProject); + } + + // Save settings + await this.plugin.saveSettings(); + + // Refresh the project list + this.loadProjects(); + } + + private deleteProject(project: Project) { + // Confirm deletion + const modal = new (class extends Modal { + private onConfirm: () => void; + + constructor(app: App, onConfirm: () => void) { + super(app); + this.onConfirm = onConfirm; + } + + onOpen() { + const { contentEl } = this; + contentEl.createEl("h2", { text: t("Delete Project") }); + contentEl.createEl("p", { + text: t(`Are you sure you want to delete "${project.displayName || project.name}"?`) + }); + contentEl.createEl("p", { + cls: "mod-warning", + text: t("This action cannot be undone.") + }); + + const buttonContainer = contentEl.createDiv({ cls: "modal-button-container" }); + + const cancelBtn = buttonContainer.createEl("button", { + text: t("Cancel") + }); + cancelBtn.addEventListener("click", () => this.close()); + + const confirmBtn = buttonContainer.createEl("button", { + text: t("Delete"), + cls: "mod-warning" + }); + confirmBtn.addEventListener("click", () => { + this.onConfirm(); + this.close(); + }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } + })(this.plugin.app, async () => { + // Remove from custom projects + if (this.plugin.settings.projectConfig?.customProjects) { + const index = this.plugin.settings.projectConfig.customProjects.findIndex( + cp => cp.id === project.id || cp.name === project.name + ); + + if (index !== -1) { + this.plugin.settings.projectConfig.customProjects.splice(index, 1); + await this.plugin.saveSettings(); + + // If this was the active project, clear selection + if (this.activeProjectId === project.id) { + this.setActiveProject(null); + this.onProjectSelect(""); + } + + // Refresh the project list + this.loadProjects(); + } + } + }); + + modal.open(); + } } diff --git a/src/experimental/v2/components/ProjectPopover.ts b/src/experimental/v2/components/ProjectPopover.ts index a21b2061..1d0821c9 100644 --- a/src/experimental/v2/components/ProjectPopover.ts +++ b/src/experimental/v2/components/ProjectPopover.ts @@ -216,17 +216,22 @@ export class ProjectPopover extends Component { private save() { if (!this.nameInput) return; - const name = this.nameInput.value.trim(); - if (!name) { + const inputValue = this.nameInput.value.trim(); + if (!inputValue) { this.nameInput.focus(); this.nameInput.addClass("is-error"); setTimeout(() => this.nameInput?.removeClass("is-error"), 300); return; } + // Convert spaces to dashes for internal name, keep original for display + const internalName = inputValue.replace(/\s+/g, '-'); + const displayName = inputValue; + const project: CustomProject = { id: `project-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - name, + name: internalName, + displayName: displayName, color: this.selectedColor, createdAt: Date.now(), updatedAt: Date.now() @@ -266,6 +271,265 @@ export class ProjectPopover extends Component { } } +export class EditProjectPopover extends Component { + private popoverEl: HTMLElement | null = null; + private plugin: TaskProgressBarPlugin; + private project: CustomProject; + private onSave: (project: CustomProject) => void; + private onClose: () => void; + private nameInput: HTMLInputElement | null = null; + private displayNameInput: HTMLInputElement | null = null; + private selectedColor: string; + private popperInstance: PopperInstance | null = null; + private referenceEl: HTMLElement; + private clickHandler: ((e: MouseEvent) => void) | null = null; + private escapeHandler: ((e: KeyboardEvent) => void) | null = null; + + private readonly colors = [ + "#e74c3c", + "#3498db", + "#2ecc71", + "#f39c12", + "#9b59b6", + "#1abc9c", + "#34495e", + "#e67e22", + "#16a085", + "#27ae60", + "#2980b9", + "#8e44ad", + "#2c3e50", + "#f1c40f", + "#d35400" + ]; + + constructor( + plugin: TaskProgressBarPlugin, + referenceEl: HTMLElement, + project: CustomProject, + onSave: (project: CustomProject) => void, + onClose: () => void + ) { + super(); + this.plugin = plugin; + this.referenceEl = referenceEl; + this.project = { ...project }; // Clone the project to avoid direct mutation + this.onSave = onSave; + this.onClose = onClose; + this.selectedColor = project.color || "#3498db"; + } + + onload() { + this.createPopover(); + this.setupPopper(); + this.setupEventHandlers(); + } + + onunload() { + this.cleanup(); + } + + private createPopover() { + this.popoverEl = document.createElement("div"); + this.popoverEl.addClass("v2-project-popover-container"); + document.body.appendChild(this.popoverEl); + + const popover = this.popoverEl.createDiv({ cls: "v2-project-popover" }); + + // Arrow + const arrow = popover.createDiv({ cls: "v2-popover-arrow" }); + arrow.setAttribute("data-popper-arrow", ""); + + // Content + const content = popover.createDiv({ cls: "v2-popover-content" }); + + // Title + const header = content.createDiv({ cls: "v2-popover-header" }); + header.createEl("h3", { text: t("Edit Project") }); + + // Display name input + const displayNameSection = content.createDiv({ cls: "v2-popover-section" }); + displayNameSection.createEl("label", { text: t("Display Name") }); + this.displayNameInput = displayNameSection.createEl("input", { + cls: "v2-popover-input", + attr: { + type: "text", + placeholder: t("Enter display name"), + value: this.project.displayName || this.project.name + } + }); + + // Color picker + const colorSection = content.createDiv({ cls: "v2-popover-section" }); + colorSection.createEl("label", { text: t("Choose Color") }); + + const colorGrid = colorSection.createDiv({ cls: "v2-color-grid" }); + this.colors.forEach(color => { + const colorButton = colorGrid.createDiv({ + cls: "v2-color-button", + attr: { "data-color": color } + }); + colorButton.style.backgroundColor = color; + + if (color === this.selectedColor) { + colorButton.addClass("is-selected"); + } + + this.registerDomEvent(colorButton, "click", () => { + this.selectColor(color); + }); + }); + + // Action buttons + const actions = content.createDiv({ cls: "v2-popover-actions" }); + + const cancelBtn = actions.createEl("button", { + cls: "v2-button v2-button-secondary", + text: t("Cancel") + }); + this.registerDomEvent(cancelBtn, "click", () => this.close()); + + const saveBtn = actions.createEl("button", { + cls: "v2-button v2-button-primary", + text: t("Save") + }); + this.registerDomEvent(saveBtn, "click", () => this.save()); + + // Handle Enter key on input + this.registerDomEvent(this.displayNameInput, "keydown", (e: KeyboardEvent) => { + if (e.key === "Enter") { + this.save(); + } + }); + + // Focus input + setTimeout(() => this.displayNameInput?.focus(), 50); + } + + private setupPopper() { + if (!this.popoverEl) return; + + const popover = this.popoverEl.querySelector(".v2-project-popover") as HTMLElement; + if (!popover) return; + + this.popperInstance = createPopper(this.referenceEl, popover, { + placement: "bottom-start", + modifiers: [ + { + name: "offset", + options: { + offset: [0, 8], + }, + }, + { + name: "arrow", + options: { + element: ".v2-popover-arrow", + padding: 8, + }, + }, + { + name: "preventOverflow", + options: { + boundary: "viewport", + padding: 8, + }, + }, + { + name: "flip", + options: { + fallbackPlacements: ["top-start", "right", "left"], + }, + }, + ], + }); + } + + private setupEventHandlers() { + // Click outside to close + this.clickHandler = (e: MouseEvent) => { + if ( + this.popoverEl && + !this.popoverEl.contains(e.target as Node) && + !this.referenceEl.contains(e.target as Node) + ) { + this.close(); + } + }; + + // Delay to avoid immediate close + setTimeout(() => { + if (this.clickHandler) { + document.addEventListener("click", this.clickHandler); + } + }, 0); + + // Escape key to close + this.escapeHandler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + this.close(); + } + }; + document.addEventListener("keydown", this.escapeHandler); + } + + private selectColor(color: string) { + if (!this.popoverEl) return; + + this.selectedColor = color; + + // Update selected state + this.popoverEl.querySelectorAll(".v2-color-button").forEach(btn => { + btn.removeClass("is-selected"); + }); + const selectedBtn = this.popoverEl.querySelector(`[data-color="${color}"]`); + selectedBtn?.addClass("is-selected"); + } + + private save() { + if (!this.displayNameInput) return; + + const displayNameValue = this.displayNameInput.value.trim(); + + // Update the project object + this.project.displayName = displayNameValue || this.project.name; + this.project.color = this.selectedColor; + this.project.updatedAt = Date.now(); + + this.onSave(this.project); + this.close(); + } + + private close() { + this.onClose(); + this.unload(); + } + + private cleanup() { + // Clean up event listeners + if (this.clickHandler) { + document.removeEventListener("click", this.clickHandler); + this.clickHandler = null; + } + if (this.escapeHandler) { + document.removeEventListener("keydown", this.escapeHandler); + this.escapeHandler = null; + } + + // Clean up Popper instance + if (this.popperInstance) { + this.popperInstance.destroy(); + this.popperInstance = null; + } + + // Remove DOM elements + if (this.popoverEl) { + this.popoverEl.remove(); + this.popoverEl = null; + } + } +} + export class ProjectModal extends Modal { private plugin: TaskProgressBarPlugin; private onSave: (project: CustomProject) => void; @@ -420,17 +684,22 @@ export class ProjectModal extends Modal { private save() { if (!this.nameInput) return; - const name = this.nameInput.value.trim(); - if (!name) { + const inputValue = this.nameInput.value.trim(); + if (!inputValue) { this.nameInput.focus(); this.nameInput.addClass("is-error"); setTimeout(() => this.nameInput?.removeClass("is-error"), 300); return; } + // Convert spaces to dashes for internal name, keep original for display + const internalName = inputValue.replace(/\s+/g, '-'); + const displayName = inputValue; + const project: CustomProject = { id: `project-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - name, + name: internalName, + displayName: displayName, color: this.selectedColor, createdAt: Date.now(), updatedAt: Date.now() @@ -439,4 +708,179 @@ export class ProjectModal extends Modal { this.onSave(project); this.close(); } +} + +export class EditProjectModal extends Modal { + private plugin: TaskProgressBarPlugin; + private project: CustomProject; + private onSave: (project: CustomProject) => void; + private nameInput: HTMLInputElement | null = null; + private displayNameInput: HTMLInputElement | null = null; + private selectedColor: string; + private eventListeners: Array<{ element: HTMLElement; event: string; handler: EventListener }> = []; + + private readonly colors = [ + "#e74c3c", + "#3498db", + "#2ecc71", + "#f39c12", + "#9b59b6", + "#1abc9c", + "#34495e", + "#e67e22", + "#16a085", + "#27ae60", + "#2980b9", + "#8e44ad", + "#2c3e50", + "#f1c40f", + "#d35400" + ]; + + constructor( + app: App, + plugin: TaskProgressBarPlugin, + project: CustomProject, + onSave: (project: CustomProject) => void + ) { + super(app); + this.plugin = plugin; + this.project = { ...project }; // Clone to avoid direct mutation + this.onSave = onSave; + this.selectedColor = project.color || "#3498db"; + } + + private addEventListener(element: HTMLElement, event: string, handler: EventListener) { + element.addEventListener(event, handler); + this.eventListeners.push({ element, event, handler }); + } + + onOpen() { + const { contentEl } = this; + + // Add custom class for styling + this.modalEl.addClass("v2-project-modal"); + + // Title + contentEl.createEl("h2", { text: t("Edit Project") }); + + // Display name input section + const displayNameSection = contentEl.createDiv({ cls: "v2-modal-section" }); + displayNameSection.createEl("label", { text: t("Display Name") }); + this.displayNameInput = displayNameSection.createEl("input", { + cls: "v2-modal-input", + attr: { + type: "text", + placeholder: t("Enter display name"), + value: this.project.displayName || this.project.name + } + }); + + // Color picker section + const colorSection = contentEl.createDiv({ cls: "v2-modal-section" }); + colorSection.createEl("label", { text: t("Choose Color") }); + + const colorGrid = colorSection.createDiv({ cls: "v2-modal-color-grid" }); + this.colors.forEach(color => { + const colorButton = colorGrid.createDiv({ + cls: "v2-modal-color-button", + attr: { "data-color": color } + }); + colorButton.style.backgroundColor = color; + + if (color === this.selectedColor) { + colorButton.addClass("is-selected"); + } + + this.addEventListener(colorButton, "click", () => { + this.selectColor(color); + }); + }); + + // Preview section + const previewSection = contentEl.createDiv({ cls: "v2-modal-section" }); + previewSection.createEl("label", { text: t("Preview") }); + const preview = previewSection.createDiv({ cls: "v2-modal-preview" }); + const previewItem = preview.createDiv({ cls: "v2-project-item-preview" }); + const previewColor = previewItem.createDiv({ cls: "v2-project-color" }); + previewColor.style.backgroundColor = this.selectedColor; + const previewName = previewItem.createSpan({ cls: "v2-project-name" }); + + // Update preview on name change + this.addEventListener(this.displayNameInput, "input", () => { + previewName.setText(this.displayNameInput?.value || this.project.name); + }); + previewName.setText(this.project.displayName || this.project.name); + + // Footer with action buttons + const footer = contentEl.createDiv({ cls: "v2-modal-footer" }); + + const cancelBtn = footer.createEl("button", { + cls: "v2-button v2-button-secondary", + text: t("Cancel") + }); + this.addEventListener(cancelBtn, "click", () => this.close()); + + const saveBtn = footer.createEl("button", { + cls: "v2-button v2-button-primary", + text: t("Save Changes") + }); + this.addEventListener(saveBtn, "click", () => this.save()); + + // Handle Enter key + this.addEventListener(this.displayNameInput, "keydown", (e: KeyboardEvent) => { + if (e.key === "Enter") { + this.save(); + } + }); + + // Focus input + setTimeout(() => { + this.displayNameInput?.focus(); + this.displayNameInput?.select(); + }, 100); + } + + onClose() { + // Clean up all event listeners + this.eventListeners.forEach(({ element, event, handler }) => { + element.removeEventListener(event, handler); + }); + this.eventListeners = []; + + // Clear content + const { contentEl } = this; + contentEl.empty(); + } + + private selectColor(color: string) { + this.selectedColor = color; + + // Update selected state + this.modalEl.querySelectorAll(".v2-modal-color-button").forEach(btn => { + btn.removeClass("is-selected"); + }); + const selectedBtn = this.modalEl.querySelector(`[data-color="${color}"]`); + selectedBtn?.addClass("is-selected"); + + // Update preview + const previewColor = this.modalEl.querySelector(".v2-project-color") as HTMLElement; + if (previewColor) { + previewColor.style.backgroundColor = color; + } + } + + private save() { + if (!this.displayNameInput) return; + + const displayNameValue = this.displayNameInput.value.trim(); + + // Update the project object + this.project.displayName = displayNameValue || this.project.name; + this.project.color = this.selectedColor; + this.project.updatedAt = Date.now(); + + this.onSave(this.project); + this.close(); + } } \ No newline at end of file