feat(projects): add context menu with edit and delete functionality

- Add right-click context menu to project items with Edit/Delete options
- Create EditProjectModal for editing project display names and colors
- Support separate display names from internal project names
- Implement project deletion with confirmation dialog
- Disable menu options for task-derived (non-custom) projects
- Add forceRefresh parameter to AutoComplete cache
This commit is contained in:
Quorafind 2025-09-22 23:08:16 +08:00
parent adc7e3e8e0
commit 743b9ca866
4 changed files with 714 additions and 22 deletions

View file

@ -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;

View file

@ -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<GlobalAutoCompleteCache> {
async function getCachedData(
plugin: TaskProgressBarPlugin,
forceRefresh: boolean = false
): Promise<GlobalAutoCompleteCache> {
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<GlobalAutoC
// Get projects and contexts from dataflow using the new convenience method
let projects: string[] = [];
let contexts: string[] = [];
if (plugin.dataflowOrchestrator) {
try {
const queryAPI = plugin.dataflowOrchestrator.getQueryAPI();
@ -40,10 +47,39 @@ async function getCachedData(plugin: TaskProgressBarPlugin): Promise<GlobalAutoC
projects = data.projects;
contexts = data.contexts;
} catch (error) {
console.warn("Failed to get projects/contexts from dataflow:", error);
console.warn(
"Failed to get projects/contexts from dataflow:",
error
);
}
}
// Merge settings-defined projects so newly added ones appear immediately
try {
const cfg = plugin.settings?.projectConfig;
if (cfg) {
// Custom projects (V2)
const custom = (cfg.customProjects || [])
.map((p) => 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;
});
}

View file

@ -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();
}
}

View file

@ -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();
}
}