feat(fluent): implement workspace management with persistent filter states

- Add WorkspaceManager for handling workspace operations
- Implement filter state persistence per workspace and view
- Add workspace settings UI in settings page with create/rename/delete
- Fix infinite refresh loop by using debounced quiet save
- Store fluentFilterState in workspace overrides for each view
- Add workspace events system for UI synchronization
- Support workspace switching with automatic state restoration
This commit is contained in:
Quorafind 2025-09-22 20:09:55 +08:00
parent 9f008db1a0
commit 2b7179ed9e
13 changed files with 1836 additions and 193 deletions

View file

@ -825,6 +825,9 @@ export interface TaskProgressBarSettings {
// FileSource Settings
fileSource: FileSourceConfiguration;
// Workspace Settings
workspaces?: import("../experimental/v2/types/workspace").WorkspacesConfig;
}
/** Define the default settings */

View file

@ -17,7 +17,9 @@ import "./styles/v2-enhanced.css";
import "./styles/v2-content-header.css";
import "@/styles/v2-project-popover.css";
import { TopNavigation, ViewMode } from "./components/V2TopNavigation";
import { Workspace, V2ViewState } from "./types";
import { V2ViewState } from "./types";
import { onWorkspaceSwitched, onWorkspaceOverridesSaved } from "./events/ui-event";
import { Events, on } from "../../dataflow/events/Events";
import { TaskListItemComponent } from "../../components/features/task/view/listItem";
import { TaskTreeItemComponent } from "../../components/features/task/view/treeItem";
import {
@ -118,13 +120,14 @@ export class TaskViewV2 extends ItemView {
// State management
private viewState: V2ViewState = {
currentWorkspace: "default",
currentWorkspace: "",
viewMode: "list", // Default should be list, not calendar
searchQuery: "",
filters: {},
};
private workspaces: Workspace[] = [];
private workspaceId: string = "";
private isSavingFilterState: boolean = false;
private tasks: Task[] = [];
private filteredTasks: Task[] = [];
@ -140,8 +143,9 @@ export class TaskViewV2 extends ItemView {
this.plugin = plugin;
this.tasks = this.plugin.preloadedTasks || [];
// Load workspaces from settings or localStorage
this.initializeDefaultWorkspaces();
// Initialize workspace ID
this.workspaceId = plugin.workspaceManager?.getActiveWorkspace().id || "";
this.viewState.currentWorkspace = this.workspaceId;
}
getViewType(): string {
@ -149,7 +153,7 @@ export class TaskViewV2 extends ItemView {
}
getDisplayText(): string {
return "Task Genius V2";
return t("Task Genius V2");
}
getIcon(): string {
@ -161,6 +165,44 @@ export class TaskViewV2 extends ItemView {
this.contentEl.empty();
this.contentEl.addClass("task-genius-v2-view");
// Subscribe to workspace events
if (this.plugin.workspaceManager) {
this.registerEvent(
onWorkspaceSwitched(this.app, async (payload) => {
if (payload.workspaceId !== this.workspaceId) {
this.workspaceId = payload.workspaceId;
this.viewState.currentWorkspace = payload.workspaceId;
await this.applyWorkspaceSettings();
this.restoreFilterStateFromWorkspace();
await this.loadTasks();
this.switchView(this.currentViewId);
}
})
);
this.registerEvent(
onWorkspaceOverridesSaved(this.app, async (payload) => {
if (payload.workspaceId === this.workspaceId) {
await this.applyWorkspaceSettings();
await this.loadTasks();
this.switchView(this.currentViewId);
}
})
);
// Skip SETTINGS_CHANGED for v2FilterState changes to avoid loops
this.registerEvent(
on(this.app, Events.SETTINGS_CHANGED, async () => {
// Only reload if not caused by filter state save
if (!this.isSavingFilterState) {
await this.applyWorkspaceSettings();
await this.loadTasks();
this.switchView(this.currentViewId);
}
})
);
}
// Initialize default view mode to list
console.log("[TG-V2] Initial viewMode:", this.viewState.viewMode);
this.viewState.viewMode = "list"; // Force list mode for now
@ -306,19 +348,12 @@ export class TaskViewV2 extends ItemView {
}
private initializeSidebar(containerEl: HTMLElement) {
const currentWorkspace =
this.workspaces.find(
(w) => w.id === this.viewState.currentWorkspace
) || this.workspaces[0];
this.sidebar = new V2Sidebar(
containerEl,
this.plugin,
currentWorkspace,
this.workspaces,
(viewId) => this.handleNavigate(viewId),
(workspace) => this.handleWorkspaceChange(workspace),
(projectId) => this.handleProjectSelect(projectId)
(projectId) => this.handleProjectSelect(projectId),
this.isSidebarCollapsed
);
// Add sidebar as a child component for proper lifecycle management
this.addChild(this.sidebar);
@ -929,18 +964,18 @@ export class TaskViewV2 extends ItemView {
// Update content header title based on view
if (this.contentTitleEl) {
const viewTitles: Record<string, string> = {
inbox: "Inbox",
today: "Today",
upcoming: "Upcoming",
flagged: "Flagged",
forecast: "Forecast",
projects: "Projects",
tags: "Tags",
review: "Review",
habit: "Habit",
calendar: "Calendar",
kanban: "Kanban",
gantt: "Gantt",
inbox: t("Inbox"),
today: t("Today"),
upcoming: t("Upcoming"),
flagged: t("Flagged"),
forecast: t("Forecast"),
projects: t("Projects"),
tags: t("Tags"),
review: t("Review"),
habit: t("Habit"),
calendar: t("Calendar"),
kanban: t("Kanban"),
gantt: t("Gantt"),
};
this.contentTitleEl.setText(t(viewTitles[viewId] || "Tasks"));
}
@ -1727,28 +1762,10 @@ export class TaskViewV2 extends ItemView {
// Workspace management
private saveWorkspaceLayout() {
const currentWorkspace = this.workspaces.find(
(w) => w.id === this.viewState.currentWorkspace
);
if (!currentWorkspace) return;
// Save filter state which includes view preferences
this.saveFilterStateToWorkspace();
// Update workspace settings
currentWorkspace.settings = {
...currentWorkspace.settings,
viewPreferences: {
viewMode: this.viewState.viewMode,
searchQuery: this.viewState.searchQuery,
filters: this.viewState.filters,
},
};
// Save all workspaces to localStorage
localStorage.setItem(
"task-genius-v2-workspaces",
JSON.stringify(this.workspaces)
);
// Save current workspace ID
// Save current workspace ID to localStorage for persistence
localStorage.setItem(
"task-genius-v2-current-workspace",
this.viewState.currentWorkspace
@ -1756,48 +1773,26 @@ export class TaskViewV2 extends ItemView {
}
private loadWorkspaceLayout() {
// Load workspaces from localStorage
const savedWorkspaces = localStorage.getItem(
"task-genius-v2-workspaces"
);
if (savedWorkspaces) {
try {
this.workspaces = JSON.parse(savedWorkspaces);
} catch (error) {
console.error("Failed to load workspaces:", error);
this.initializeDefaultWorkspaces();
}
} else {
this.initializeDefaultWorkspaces();
}
// Load current workspace
// Load current workspace from localStorage
const savedCurrentWorkspace = localStorage.getItem(
"task-genius-v2-current-workspace"
);
if (savedCurrentWorkspace) {
this.viewState.currentWorkspace = savedCurrentWorkspace;
this.workspaceId = savedCurrentWorkspace;
}
// Apply workspace settings
const currentWorkspace = this.workspaces.find(
(w) => w.id === this.viewState.currentWorkspace
);
if (currentWorkspace?.settings?.viewPreferences) {
const prefs = currentWorkspace.settings.viewPreferences;
if (prefs.viewMode) this.viewState.viewMode = prefs.viewMode;
if (prefs.searchQuery !== undefined)
this.viewState.searchQuery = prefs.searchQuery;
if (prefs.filters) this.viewState.filters = prefs.filters;
}
// Restore filter state which includes view preferences
this.restoreFilterStateFromWorkspace();
}
private initializeDefaultWorkspaces() {
this.workspaces = [
{ id: "default", name: "Default", color: "#7c3aed" },
{ id: "personal", name: "Personal", color: "#3b82f6" },
{ id: "work", name: "Work", color: "#10b981" },
];
private async applyWorkspaceSettings() {
if (!this.plugin.workspaceManager || !this.workspaceId) return;
const settings = this.plugin.workspaceManager.getEffectiveSettings(this.workspaceId);
// Workspace settings are now restored via restoreFilterStateFromWorkspace
// This method is kept for future workspace-specific settings that are not filter-related
}
private switchWorkspace(workspaceId: string) {
@ -1806,16 +1801,10 @@ export class TaskViewV2 extends ItemView {
// Update workspace ID
this.viewState.currentWorkspace = workspaceId;
this.workspaceId = workspaceId;
// Load new workspace settings
const newWorkspace = this.workspaces.find((w) => w.id === workspaceId);
if (newWorkspace?.settings?.viewPreferences) {
const prefs = newWorkspace.settings.viewPreferences;
if (prefs.viewMode) this.viewState.viewMode = prefs.viewMode;
if (prefs.searchQuery !== undefined)
this.viewState.searchQuery = prefs.searchQuery;
if (prefs.filters) this.viewState.filters = prefs.filters;
}
// Restore filter state from new workspace
this.restoreFilterStateFromWorkspace();
// Apply filters and update view
this.applyFilters();
@ -1824,7 +1813,7 @@ export class TaskViewV2 extends ItemView {
// Update UI to reflect workspace change
if (this.sidebar) {
// Update sidebar workspace selection if needed
const workspace = this.workspaces.find((w) => w.id === workspaceId);
const workspace = this.plugin.workspaceManager?.getWorkspace(workspaceId);
if (workspace) {
this.sidebar.updateWorkspace(workspace);
}
@ -1980,14 +1969,26 @@ export class TaskViewV2 extends ItemView {
this.updateActionButtons();
}
private handleWorkspaceChange(workspace: Workspace) {
this.viewState.currentWorkspace = workspace.id;
this.sidebar?.updateWorkspace(workspace);
this.loadTasks().then(() => {
this.switchView(this.currentViewId);
this.topNavigation?.refresh();
});
new Notice(`Switched to ${workspace.name} workspace`);
private async handleWorkspaceChange(workspaceId: string) {
if (!this.plugin.workspaceManager) return;
// Save to workspace manager
await this.plugin.workspaceManager.setActiveWorkspace(workspaceId);
// Update local state
this.workspaceId = workspaceId;
this.viewState.currentWorkspace = workspaceId;
// Update sidebar
this.sidebar?.updateWorkspace(workspaceId);
// Apply workspace settings and reload
await this.applyWorkspaceSettings();
await this.loadTasks();
// Refresh current view
this.switchView(this.currentViewId);
this.topNavigation?.refresh();
}
private handleProjectSelect(projectId: string) {
@ -2465,9 +2466,56 @@ export class TaskViewV2 extends ItemView {
this.topNavigation?.refresh();
}
// Save filter state to workspace - debounced to avoid infinite loops
private saveFilterStateToWorkspace = debounce(() => {
if (!this.plugin.workspaceManager || !this.workspaceId) return;
const effectiveSettings = this.plugin.workspaceManager.getEffectiveSettings(this.workspaceId);
// Save current filter state
if (!effectiveSettings.v2FilterState) {
effectiveSettings.v2FilterState = {};
}
effectiveSettings.v2FilterState[this.currentViewId] = {
filters: this.viewState.filters,
searchQuery: this.viewState.searchQuery,
selectedProject: this.viewState.selectedProject,
advancedFilter: this.currentFilterState,
viewMode: this.viewState.viewMode
};
// Use saveOverridesQuietly to avoid triggering SETTINGS_CHANGED event
this.plugin.workspaceManager.saveOverridesQuietly(this.workspaceId, effectiveSettings);
}, 500, true)
// Restore filter state from workspace
private restoreFilterStateFromWorkspace() {
if (!this.plugin.workspaceManager || !this.workspaceId) return;
const effectiveSettings = this.plugin.workspaceManager.getEffectiveSettings(this.workspaceId);
if (effectiveSettings.v2FilterState && effectiveSettings.v2FilterState[this.currentViewId]) {
const savedState = effectiveSettings.v2FilterState[this.currentViewId];
// Restore filter state
this.viewState.filters = savedState.filters || {};
this.viewState.searchQuery = savedState.searchQuery || "";
this.viewState.selectedProject = savedState.selectedProject || null;
this.currentFilterState = savedState.advancedFilter || null;
this.viewState.viewMode = savedState.viewMode || "list";
// Update UI elements
if (this.filterInputEl) {
this.filterInputEl.value = this.viewState.searchQuery || "";
}
}
}
async onClose() {
// Save workspace layout
// Save workspace layout and filter state
this.saveWorkspaceLayout();
this.saveFilterStateToWorkspace();
// Clean up components
if (this.kanbanComponent) {

View file

@ -3,6 +3,7 @@ import TaskProgressBarPlugin from "../../../index";
import { Task } from "../../../types/task";
import { ProjectPopover, ProjectModal } from "./ProjectPopover";
import type { CustomProject } from "../../../common/setting-definition";
import { t } from "@/translations/helper";
interface Project {
id: string;
@ -221,7 +222,7 @@ export class ProjectList extends Component {
addProjectBtn.createSpan({
cls: "v2-project-name",
text: "Add Project",
text: t("Add Project"),
});
this.registerDomEvent(addProjectBtn, "click", () => {
@ -374,25 +375,25 @@ export class ProjectList extends Component {
value: SortOption;
icon: string;
}[] = [
{ label: "Name (A-Z)", value: "name-asc", icon: "arrow-up-a-z" },
{ label: "Name (Z-A)", value: "name-desc", icon: "arrow-down-a-z" },
{ label: t("Name (A-Z)"), value: "name-asc", icon: "arrow-up-a-z" },
{ label: t("Name (Z-A)"), value: "name-desc", icon: "arrow-down-a-z" },
{
label: "Tasks (Low to High)",
label: t("Tasks (Low to High)"),
value: "tasks-asc",
icon: "arrow-up-1-0",
},
{
label: "Tasks (High to Low)",
label: t("Tasks (High to Low)"),
value: "tasks-desc",
icon: "arrow-down-1-0",
},
{
label: "Created (Oldest First)",
label: t("Created (Oldest First)"),
value: "created-asc",
icon: "clock",
},
{
label: "Created (Newest First)",
label: t("Created (Newest First)"),
value: "created-desc",
icon: "history",
},

View file

@ -2,6 +2,7 @@ import { Component, Platform, Modal, App } from "obsidian";
import { createPopper, Instance as PopperInstance } from "@popperjs/core";
import TaskProgressBarPlugin from "../../../index";
import type { CustomProject } from "../../../common/setting-definition";
import { t } from "@/translations/helper";
export class ProjectPopover extends Component {
private popoverEl: HTMLElement | null = null;
@ -72,22 +73,22 @@ export class ProjectPopover extends Component {
// Title
const header = content.createDiv({ cls: "v2-popover-header" });
header.createEl("h3", { text: "New Project" });
header.createEl("h3", { text: t("New Project") });
// Name input
const nameSection = content.createDiv({ cls: "v2-popover-section" });
nameSection.createEl("label", { text: "Project Name" });
nameSection.createEl("label", { text: t("Project Name") });
this.nameInput = nameSection.createEl("input", {
cls: "v2-popover-input",
attr: {
type: "text",
placeholder: "Enter project name",
placeholder: t("Enter project name"),
}
});
// Color picker
const colorSection = content.createDiv({ cls: "v2-popover-section" });
colorSection.createEl("label", { text: "Choose Color" });
colorSection.createEl("label", { text: t("Choose Color") });
const colorGrid = colorSection.createDiv({ cls: "v2-color-grid" });
this.colors.forEach(color => {
@ -111,13 +112,13 @@ export class ProjectPopover extends Component {
const cancelBtn = actions.createEl("button", {
cls: "v2-button v2-button-secondary",
text: "Cancel"
text: t("Cancel")
});
this.registerDomEvent(cancelBtn, "click", () => this.close());
const saveBtn = actions.createEl("button", {
cls: "v2-button v2-button-primary",
text: "Create"
text: t("Create")
});
this.registerDomEvent(saveBtn, "click", () => this.save());
@ -312,22 +313,22 @@ export class ProjectModal extends Modal {
this.modalEl.addClass("v2-project-modal");
// Title
contentEl.createEl("h2", { text: "Create New Project" });
contentEl.createEl("h2", { text: t("Create New Project") });
// Name input section
const nameSection = contentEl.createDiv({ cls: "v2-modal-section" });
nameSection.createEl("label", { text: "Project Name" });
nameSection.createEl("label", { text: t("Project Name") });
this.nameInput = nameSection.createEl("input", {
cls: "v2-modal-input",
attr: {
type: "text",
placeholder: "Enter project name"
placeholder: t("Enter project name")
}
});
// Color picker section
const colorSection = contentEl.createDiv({ cls: "v2-modal-section" });
colorSection.createEl("label", { text: "Choose Color" });
colorSection.createEl("label", { text: t("Choose Color") });
const colorGrid = colorSection.createDiv({ cls: "v2-modal-color-grid" });
this.colors.forEach(color => {
@ -348,7 +349,7 @@ export class ProjectModal extends Modal {
// Preview section
const previewSection = contentEl.createDiv({ cls: "v2-modal-section" });
previewSection.createEl("label", { text: "Preview" });
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" });
@ -357,22 +358,22 @@ export class ProjectModal extends Modal {
// Update preview on name change
this.addEventListener(this.nameInput, "input", () => {
previewName.setText(this.nameInput?.value || "Project Name");
previewName.setText(this.nameInput?.value || t("Project Name"));
});
previewName.setText("Project Name");
previewName.setText(t("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: "Cancel"
text: t("Cancel")
});
this.addEventListener(cancelBtn, "click", () => this.close());
const createBtn = footer.createEl("button", {
cls: "v2-button v2-button-primary",
text: "Create Project"
text: t("Create Project")
});
this.addEventListener(createBtn, "click", () => this.save());

View file

@ -1,7 +1,13 @@
import { Component, setIcon, Menu } from "obsidian";
import { Component, setIcon, Menu, Notice, Modal } from "obsidian";
import { WorkspaceSelector } from "./WorkspaceSelector";
import { ProjectList } from "@/experimental/v2/components/ProjectList";
import { Workspace, V2NavigationItem } from "@/experimental/v2/types";
import { V2NavigationItem } from "@/experimental/v2/types";
import { WorkspaceData } from "@/experimental/v2/types/workspace";
import {
onWorkspaceSwitched,
onWorkspaceDeleted,
onWorkspaceCreated,
} from "@/experimental/v2/events/ui-event";
import TaskProgressBarPlugin from "@/index";
import { t } from "@/translations/helper";
@ -11,6 +17,7 @@ export class V2Sidebar extends Component {
private workspaceSelector: WorkspaceSelector;
public projectList: ProjectList;
private collapsed: boolean = false;
private currentWorkspaceId: string;
private primaryItems: V2NavigationItem[] = [
{ id: "inbox", label: t("Inbox"), icon: "inbox", type: "primary" },
@ -49,10 +56,7 @@ export class V2Sidebar extends Component {
constructor(
containerEl: HTMLElement,
plugin: TaskProgressBarPlugin,
private currentWorkspace: Workspace,
private workspaces: Workspace[],
private onNavigate: (viewId: string) => void,
private onWorkspaceChange: (workspace: Workspace) => void,
private onProjectSelect: (projectId: string) => void,
collapsed: boolean = false
) {
@ -60,6 +64,8 @@ export class V2Sidebar extends Component {
this.containerEl = containerEl;
this.plugin = plugin;
this.collapsed = collapsed;
this.currentWorkspaceId =
plugin.workspaceManager?.getActiveWorkspace().id || "";
}
private render() {
@ -78,7 +84,7 @@ export class V2Sidebar extends Component {
});
setIcon(wsBtn, "layers");
wsBtn.addEventListener("click", (e) =>
this.showWorkspaceMenu(e as MouseEvent)
this.showWorkspaceMenuWithManager(e as MouseEvent)
);
// Primary view icons
@ -152,17 +158,18 @@ export class V2Sidebar extends Component {
const header = this.containerEl.createDiv({ cls: "v2-sidebar-header" });
const workspaceSelectorEl = header.createDiv();
this.workspaceSelector = new WorkspaceSelector(
workspaceSelectorEl,
this.workspaces,
this.currentWorkspace,
this.onWorkspaceChange
);
if (this.plugin.workspaceManager) {
this.workspaceSelector = new WorkspaceSelector(
workspaceSelectorEl,
this.plugin,
(workspaceId: string) => this.handleWorkspaceChange(workspaceId)
);
}
// New Task Button
const newTaskBtn = header.createEl("button", {
cls: "v2-new-task-btn",
text: "New Task",
text: t("New Task"),
});
setIcon(newTaskBtn.createDiv({ cls: "v2-new-task-icon" }), "plus");
newTaskBtn.addEventListener("click", () => {
@ -186,10 +193,10 @@ export class V2Sidebar extends Component {
cls: "v2-section-header",
});
projectHeader.createSpan({ text: "Projects" });
projectHeader.createSpan({ text: t("Projects") });
const sortProjectBtn = projectHeader.createDiv({
cls: "v2-sort-project-btn",
attr: { "aria-label": "Sort projects" }
attr: { "aria-label": t("Sort projects") },
});
setIcon(sortProjectBtn, "arrow-up-down");
@ -220,7 +227,7 @@ export class V2Sidebar extends Component {
);
const remainingOther: V2NavigationItem[] =
allOtherItems.slice(visibleCount);
otherHeader.createSpan({ text: "Other Views" });
otherHeader.createSpan({ text: t("Other Views") });
if (remainingOther.length > 0) {
const moreBtn = otherHeader.createDiv({
cls: "v2-section-action",
@ -267,6 +274,28 @@ export class V2Sidebar extends Component {
onload() {
this.render();
// Subscribe to workspace events
if (this.plugin.workspaceManager) {
this.registerEvent(
onWorkspaceSwitched(this.plugin.app, (payload) => {
this.currentWorkspaceId = payload.workspaceId;
this.render();
})
);
this.registerEvent(
onWorkspaceDeleted(this.plugin.app, () => {
this.render();
})
);
this.registerEvent(
onWorkspaceCreated(this.plugin.app, () => {
this.render();
})
);
}
}
onunload() {
@ -279,23 +308,116 @@ export class V2Sidebar extends Component {
this.render();
}
private showWorkspaceMenu(event: MouseEvent) {
private async handleWorkspaceChange(workspaceId: string) {
if (this.plugin.workspaceManager) {
await this.plugin.workspaceManager.setActiveWorkspace(workspaceId);
this.currentWorkspaceId = workspaceId;
}
}
private showWorkspaceMenuWithManager(event: MouseEvent) {
if (!this.plugin.workspaceManager) return;
const menu = new Menu();
this.workspaces.forEach((w) => {
const workspaces = this.plugin.workspaceManager.getAllWorkspaces();
const currentWorkspace =
this.plugin.workspaceManager.getActiveWorkspace();
workspaces.forEach((w) => {
menu.addItem((item) => {
item.setTitle(w.name)
const isDefault =
this.plugin.workspaceManager?.isDefaultWorkspace(w.id);
const title = isDefault ? `${w.name} 🔒` : w.name;
item.setTitle(title)
.setIcon("layers")
.onClick(() => {
this.currentWorkspace = w;
this.onWorkspaceChange(w);
this.render();
.onClick(async () => {
await this.handleWorkspaceChange(w.id);
});
if (w.id === this.currentWorkspace.id) item.setChecked(true);
if (w.id === currentWorkspace.id) item.setChecked(true);
});
});
menu.addSeparator();
menu.addItem((item) => {
item.setTitle(t("Create Workspace"))
.setIcon("plus")
.onClick(() => this.showCreateWorkspaceDialog());
});
menu.showAtMouseEvent(event);
}
private showCreateWorkspaceDialog() {
class CreateWorkspaceModal extends Modal {
private nameInput: HTMLInputElement;
constructor(
private plugin: TaskProgressBarPlugin,
private onCreated: () => void
) {
super(plugin.app);
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h2", { text: t("Create New Workspace") });
const inputContainer = contentEl.createDiv();
inputContainer.createEl("label", {
text: t("Workspace Name:"),
});
this.nameInput = inputContainer.createEl("input", {
type: "text",
placeholder: t("Enter workspace name..."),
});
const buttonContainer = contentEl.createDiv({
cls: "modal-button-container",
});
const createButton = buttonContainer.createEl("button", {
text: t("Create"),
});
const cancelButton = buttonContainer.createEl("button", {
text: t("Cancel"),
});
createButton.addEventListener("click", async () => {
const name = this.nameInput.value.trim();
if (name && this.plugin.workspaceManager) {
await this.plugin.workspaceManager.createWorkspace(
name
);
new Notice(
t('Workspace "{{name}}" created', {
interpolation: {
name: name,
},
})
);
this.onCreated();
this.close();
} else {
new Notice(t("Please enter a workspace name"));
}
});
cancelButton.addEventListener("click", () => {
this.close();
});
this.nameInput.focus();
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
new CreateWorkspaceModal(this.plugin, () => this.render()).open();
}
private showProjectMenu(event: MouseEvent) {
// Try to use existing project list data; if missing, build a temporary one
let projects: any[] = [];
@ -381,9 +503,13 @@ export class V2Sidebar extends Component {
activeEls.forEach((el) => el.addClass("is-active"));
}
public updateWorkspace(workspace: Workspace) {
this.currentWorkspace = workspace;
this.workspaceSelector?.setWorkspace(workspace);
public updateWorkspace(workspaceOrId: string | WorkspaceData) {
const workspaceId =
typeof workspaceOrId === "string"
? workspaceOrId
: workspaceOrId.id;
this.currentWorkspaceId = workspaceId;
this.workspaceSelector?.setWorkspace(workspaceId);
this.projectList?.refresh();
}
}

View file

@ -183,10 +183,10 @@ export class TopNavigation {
overdueTasks.forEach((task) => {
menu.addItem((item) => {
item.setTitle(task.content || "Untitled task")
item.setTitle(task.content || t("Untitled task"))
.setIcon("alert-circle")
.onClick(() => {
new Notice(`Task: ${task.content}`);
new Notice(t("Task: {{content}}", { content: task.content || "" }));
});
});
});

View file

@ -1,21 +1,23 @@
import { Menu, setIcon } from 'obsidian';
import { Workspace } from '../types';
import { Menu, setIcon, Notice, Modal, Setting } from "obsidian";
import type TaskProgressBarPlugin from "@/index";
import { WorkspaceData } from "@/experimental/v2/types/workspace";
import { t } from "@/translations/helper";
export class WorkspaceSelector {
private containerEl: HTMLElement;
private currentWorkspace: Workspace;
private workspaces: Workspace[];
private onWorkspaceChange: (workspace: Workspace) => void;
private plugin: TaskProgressBarPlugin;
private currentWorkspaceId: string;
private onWorkspaceChange: (workspaceId: string) => void;
constructor(
containerEl: HTMLElement,
workspaces: Workspace[],
currentWorkspace: Workspace,
onWorkspaceChange: (workspace: Workspace) => void
plugin: TaskProgressBarPlugin,
onWorkspaceChange: (workspaceId: string) => void
) {
this.containerEl = containerEl;
this.workspaces = workspaces;
this.currentWorkspace = currentWorkspace;
this.plugin = plugin;
this.currentWorkspaceId =
plugin.workspaceManager?.getActiveWorkspace().id || "";
this.onWorkspaceChange = onWorkspaceChange;
this.render();
@ -23,59 +25,108 @@ export class WorkspaceSelector {
private render() {
this.containerEl.empty();
this.containerEl.addClass('workspace-selector');
this.containerEl.addClass("workspace-selector");
if (!this.plugin.workspaceManager) return;
const currentWorkspace =
this.plugin.workspaceManager.getActiveWorkspace();
const isDefault = this.plugin.workspaceManager.isDefaultWorkspace(
currentWorkspace.id
);
const selectorButton = this.containerEl.createDiv({
cls: 'workspace-selector-button',
cls: "workspace-selector-button",
});
const workspaceInfo = selectorButton.createDiv({
cls: 'workspace-info'
cls: "workspace-info",
});
const workspaceIcon = workspaceInfo.createDiv({
cls: 'workspace-icon',
cls: "workspace-icon",
});
workspaceIcon.style.backgroundColor = this.currentWorkspace.color;
setIcon(workspaceIcon, 'layers');
// Use a color scheme for workspaces if needed
workspaceIcon.style.backgroundColor =
this.getWorkspaceColor(currentWorkspace);
setIcon(workspaceIcon, "layers");
const workspaceDetails = workspaceInfo.createDiv({
cls: 'workspace-details'
cls: "workspace-details",
});
workspaceDetails.createDiv({
cls: 'workspace-name',
text: this.currentWorkspace.name
const nameContainer = workspaceDetails.createDiv({
cls: "workspace-name-container",
});
nameContainer.createSpan({
cls: "workspace-name",
text: currentWorkspace.name,
});
workspaceDetails.createDiv({
cls: 'workspace-label',
text: 'Workspace'
cls: "workspace-label",
text: t("Workspace"),
});
const dropdownIcon = selectorButton.createDiv({
cls: 'workspace-dropdown-icon'
cls: "workspace-dropdown-icon",
});
setIcon(dropdownIcon, 'chevron-down');
setIcon(dropdownIcon, "chevron-down");
selectorButton.addEventListener('click', (e) => {
selectorButton.addEventListener("click", (e) => {
e.preventDefault();
this.showWorkspaceMenu(e);
});
}
private showWorkspaceMenu(event: MouseEvent) {
const menu = new Menu();
private getWorkspaceColor(workspace: WorkspaceData): string {
// Generate a color based on workspace ID or use predefined colors
const colors = [
"#e74c3c",
"#3498db",
"#2ecc71",
"#f39c12",
"#9b59b6",
"#1abc9c",
"#34495e",
"#e67e22",
];
const index =
Math.abs(
workspace.id
.split("")
.reduce((acc, char) => acc + char.charCodeAt(0), 0)
) % colors.length;
return colors[index];
}
this.workspaces.forEach(workspace => {
menu.addItem(item => {
item.setTitle(workspace.name)
.setIcon('layers')
.onClick(() => {
this.currentWorkspace = workspace;
this.onWorkspaceChange(workspace);
private showWorkspaceMenu(event: MouseEvent) {
if (!this.plugin.workspaceManager) return;
const menu = new Menu();
const workspaces = this.plugin.workspaceManager.getAllWorkspaces();
const currentWorkspace =
this.plugin.workspaceManager.getActiveWorkspace();
// Add workspace items
workspaces.forEach((workspace) => {
menu.addItem((item) => {
const isDefault =
this.plugin.workspaceManager?.isDefaultWorkspace(
workspace.id
);
const title = isDefault ? `${workspace.name}` : workspace.name;
item.setTitle(title)
.setIcon("layers")
.onClick(async () => {
await this.onWorkspaceChange(workspace.id);
this.currentWorkspaceId = workspace.id;
this.render();
});
if (workspace.id === this.currentWorkspace.id) {
if (workspace.id === currentWorkspace.id) {
item.setChecked(true);
}
});
@ -83,19 +134,319 @@ export class WorkspaceSelector {
menu.addSeparator();
menu.addItem(item => {
item.setTitle('Create Workspace')
.setIcon('plus')
// Add management options
menu.addItem((item) => {
item.setTitle(t("Create Workspace"))
.setIcon("plus")
.onClick(() => {
console.log('Create new workspace');
this.showCreateWorkspaceDialog();
});
});
// Only show rename/delete for non-default workspaces
if (
!this.plugin.workspaceManager.isDefaultWorkspace(
currentWorkspace.id
)
) {
menu.addItem((item) => {
item.setTitle(t("Rename Current Workspace"))
.setIcon("edit")
.onClick(() => {
this.showRenameWorkspaceDialog(currentWorkspace);
});
});
menu.addItem((item) => {
item.setTitle(t("Delete Current Workspace"))
.setIcon("trash")
.onClick(() => {
this.showDeleteWorkspaceDialog(currentWorkspace);
});
});
}
menu.addSeparator();
menu.addItem((item) => {
item.setTitle(t("Manage Workspaces..."))
.setIcon("settings")
.onClick(() => {
// Open settings to workspace tab
// @ts-ignore
this.plugin.app.setting.open();
// @ts-ignore
this.plugin.app.setting.openTabById(
"obsidian-task-progress-bar"
);
});
});
menu.showAtMouseEvent(event);
}
public setWorkspace(workspace: Workspace) {
this.currentWorkspace = workspace;
private showCreateWorkspaceDialog() {
class CreateWorkspaceModal extends Modal {
private nameInput: HTMLInputElement;
private baseWorkspaceSelect: HTMLSelectElement;
private name: string = "";
private selectWorkspaceId: string = "";
constructor(
private plugin: TaskProgressBarPlugin,
private onCreated: (workspace: WorkspaceData) => void
) {
super(plugin.app);
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h2", { text: t("Create New Workspace") });
// Name input
new Setting(contentEl)
.setName(t("Workspace Name"))
.setDesc(t("A descriptive name for the workspace"))
.addText((text) => {
text.setPlaceholder(t("Workspace Name"))
.setValue("")
.onChange((value) => {
this.name = value;
});
});
const currentWorkspace =
this.plugin.workspaceManager?.getActiveWorkspace();
// Base workspace selector
new Setting(contentEl)
.setName(t("Copy Settings From"))
.setDesc(t("Copy settings from an existing workspace"))
.addDropdown((dropdown) => {
dropdown.addOption("", t("Select a workspace..."));
if (currentWorkspace) {
dropdown.addOption(
currentWorkspace.id,
`Current (${currentWorkspace.name})`
);
}
this.plugin.workspaceManager
?.getAllWorkspaces()
.forEach((ws) => {
dropdown.addOption(ws.id, ws.name);
});
dropdown.onChange((value) => {
this.selectWorkspaceId = value;
});
});
// Buttons
const buttonContainer = contentEl.createDiv({
cls: "modal-button-container",
});
const createButton = buttonContainer.createEl("button", {
text: t("Create"),
cls: "mod-cta",
});
const cancelButton = buttonContainer.createEl("button", {
text: t("Cancel"),
});
createButton.addEventListener("click", async () => {
const name = this.name.trim();
if (!name) {
new Notice(t("Please enter a workspace name"));
return;
}
const baseId = this.selectWorkspaceId;
if (name && this.plugin.workspaceManager) {
const workspace =
await this.plugin.workspaceManager.createWorkspace(
name,
baseId
);
new Notice(
t('Workspace "{{name}}" created', {
interpolation: { name: name },
})
);
this.onCreated(workspace);
this.close();
} else {
new Notice(t("Please enter a workspace name"));
}
});
cancelButton.addEventListener("click", () => {
this.close();
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
new CreateWorkspaceModal(this.plugin, (workspace) => {
this.onWorkspaceChange(workspace.id);
this.currentWorkspaceId = workspace.id;
this.render();
}).open();
}
private showRenameWorkspaceDialog(workspace: WorkspaceData) {
class RenameWorkspaceModal extends Modal {
private nameInput: HTMLInputElement;
constructor(
private plugin: TaskProgressBarPlugin,
private workspace: WorkspaceData,
private onRenamed: () => void
) {
super(plugin.app);
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h2", { text: t("Rename Workspace") });
const inputContainer = contentEl.createDiv({
cls: "setting-item",
});
inputContainer.createEl("label", { text: t("New Name") + ":" });
this.nameInput = inputContainer.createEl("input", {
type: "text",
value: this.workspace.name,
placeholder: t("Enter workspace name..."),
});
const buttonContainer = contentEl.createDiv({
cls: "modal-button-container",
});
const renameButton = buttonContainer.createEl("button", {
text: t("Rename"),
cls: "mod-cta",
});
const cancelButton = buttonContainer.createEl("button", {
text: t("Cancel"),
});
renameButton.addEventListener("click", async () => {
const newName = this.nameInput.value.trim();
if (
newName &&
newName !== this.workspace.name &&
this.plugin.workspaceManager
) {
await this.plugin.workspaceManager.renameWorkspace(
this.workspace.id,
newName
);
new Notice(
t('Workspace renamed to "{{name}}"', {
interpolation: { name: newName },
})
);
this.onRenamed();
this.close();
} else {
new Notice(t("Please enter a different name"));
}
});
cancelButton.addEventListener("click", () => {
this.close();
});
this.nameInput.focus();
this.nameInput.select();
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
new RenameWorkspaceModal(this.plugin, workspace, () => {
this.render();
}).open();
}
private showDeleteWorkspaceDialog(workspace: WorkspaceData) {
class DeleteWorkspaceModal extends Modal {
constructor(
private plugin: TaskProgressBarPlugin,
private workspace: WorkspaceData,
private onDeleted: () => void
) {
super(plugin.app);
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h2", { text: t("Delete Workspace") });
contentEl.createEl("p", {
text: t(
'Are you sure you want to delete the workspace "{{name}}"? This action cannot be undone.',
{ interpolation: { name: this.workspace.name } }
),
});
const buttonContainer = contentEl.createDiv({
cls: "modal-button-container",
});
const deleteButton = buttonContainer.createEl("button", {
text: t("Delete"),
cls: "mod-warning",
});
const cancelButton = buttonContainer.createEl("button", {
text: t("Cancel"),
});
deleteButton.addEventListener("click", async () => {
if (this.plugin.workspaceManager) {
await this.plugin.workspaceManager.deleteWorkspace(
this.workspace.id
);
new Notice(
t('Workspace "{{name}}" deleted', {
interpolation: {
name: this.workspace.name,
},
})
);
this.onDeleted();
this.close();
}
});
cancelButton.addEventListener("click", () => {
this.close();
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
new DeleteWorkspaceModal(this.plugin, workspace, () => {
// After deletion, workspace manager will automatically switch to default
this.currentWorkspaceId =
this.plugin.workspaceManager?.getActiveWorkspace().id || "";
this.render();
}).open();
}
public setWorkspace(workspaceId: string) {
this.currentWorkspaceId = workspaceId;
this.render();
}
}

View file

@ -0,0 +1,102 @@
import type { App, EventRef } from "obsidian";
import { emit as coreEmit, on as coreOn, Seq } from "../../../dataflow/events/Events";
export const UIEvents = {
WORKSPACE_SWITCHED: "task-genius:ui-workspace-switched",
WORKSPACE_OVERRIDES_SAVED: "task-genius:ui-workspace-overrides-saved",
WORKSPACE_RESET: "task-genius:ui-workspace-reset",
DEFAULT_WORKSPACE_CHANGED: "task-genius:ui-default-workspace-changed",
WORKSPACE_CREATED: "task-genius:ui-workspace-created",
WORKSPACE_DELETED: "task-genius:ui-workspace-deleted",
WORKSPACE_RENAMED: "task-genius:ui-workspace-renamed"
} as const;
export interface WorkspaceEventPayload {
workspaceId: string;
seq: number;
changedKeys?: string[];
baseId?: string; // For cloned workspaces
newName?: string; // For rename events
}
// Event emitters
export const emitWorkspaceSwitched = (app: App, workspaceId: string) => {
coreEmit(app, UIEvents.WORKSPACE_SWITCHED, {
workspaceId,
seq: Seq.next()
} as WorkspaceEventPayload);
};
export const emitWorkspaceOverridesSaved = (app: App, workspaceId: string, changedKeys?: string[]) => {
coreEmit(app, UIEvents.WORKSPACE_OVERRIDES_SAVED, {
workspaceId,
changedKeys,
seq: Seq.next()
} as WorkspaceEventPayload);
};
export const emitWorkspaceReset = (app: App, workspaceId: string) => {
coreEmit(app, UIEvents.WORKSPACE_RESET, {
workspaceId,
seq: Seq.next()
} as WorkspaceEventPayload);
};
export const emitDefaultWorkspaceChanged = (app: App, workspaceId: string) => {
coreEmit(app, UIEvents.DEFAULT_WORKSPACE_CHANGED, {
workspaceId,
seq: Seq.next()
} as WorkspaceEventPayload);
};
export const emitWorkspaceCreated = (app: App, workspaceId: string, baseId?: string) => {
coreEmit(app, UIEvents.WORKSPACE_CREATED, {
workspaceId,
baseId,
seq: Seq.next()
} as WorkspaceEventPayload);
};
export const emitWorkspaceDeleted = (app: App, workspaceId: string) => {
coreEmit(app, UIEvents.WORKSPACE_DELETED, {
workspaceId,
seq: Seq.next()
} as WorkspaceEventPayload);
};
export const emitWorkspaceRenamed = (app: App, workspaceId: string, newName: string) => {
coreEmit(app, UIEvents.WORKSPACE_RENAMED, {
workspaceId,
newName,
seq: Seq.next()
} as WorkspaceEventPayload);
};
// Event subscribers
export const onWorkspaceSwitched = (app: App, handler: (payload: WorkspaceEventPayload) => void): EventRef => {
return coreOn(app, UIEvents.WORKSPACE_SWITCHED, handler);
};
export const onWorkspaceOverridesSaved = (app: App, handler: (payload: WorkspaceEventPayload) => void): EventRef => {
return coreOn(app, UIEvents.WORKSPACE_OVERRIDES_SAVED, handler);
};
export const onWorkspaceReset = (app: App, handler: (payload: WorkspaceEventPayload) => void): EventRef => {
return coreOn(app, UIEvents.WORKSPACE_RESET, handler);
};
export const onDefaultWorkspaceChanged = (app: App, handler: (payload: WorkspaceEventPayload) => void): EventRef => {
return coreOn(app, UIEvents.DEFAULT_WORKSPACE_CHANGED, handler);
};
export const onWorkspaceCreated = (app: App, handler: (payload: WorkspaceEventPayload) => void): EventRef => {
return coreOn(app, UIEvents.WORKSPACE_CREATED, handler);
};
export const onWorkspaceDeleted = (app: App, handler: (payload: WorkspaceEventPayload) => void): EventRef => {
return coreOn(app, UIEvents.WORKSPACE_DELETED, handler);
};
export const onWorkspaceRenamed = (app: App, handler: (payload: WorkspaceEventPayload) => void): EventRef => {
return coreOn(app, UIEvents.WORKSPACE_RENAMED, handler);
};

View file

@ -0,0 +1,523 @@
import { App, Notice } from "obsidian";
import {
WorkspaceData,
WorkspacesConfig,
EffectiveSettings,
WORKSPACE_SCOPED_KEYS,
WorkspaceOverrides
} from "../types/workspace";
import {
emitWorkspaceSwitched,
emitWorkspaceOverridesSaved,
emitWorkspaceReset,
emitDefaultWorkspaceChanged,
emitWorkspaceCreated,
emitWorkspaceDeleted,
emitWorkspaceRenamed
} from "../events/ui-event";
import { Events } from "../../../dataflow/events/Events";
import { emit } from "../../../dataflow/events/Events";
import type TaskProgressBarPlugin from "../../../index";
export class WorkspaceManager {
private app: App;
private plugin: TaskProgressBarPlugin;
private effectiveCache: Map<string, EffectiveSettings> = new Map();
constructor(plugin: TaskProgressBarPlugin) {
this.plugin = plugin;
this.app = plugin.app;
}
// Get the workspace configuration, initializing if needed
private getWorkspacesConfig(): WorkspacesConfig {
if (!this.plugin.settings.workspaces) {
return this.initializeWorkspaces();
}
return this.plugin.settings.workspaces;
}
// Initialize workspace system
private initializeWorkspaces(): WorkspacesConfig {
const defaultId = this.generateId();
this.plugin.settings.workspaces = {
version: 2,
defaultWorkspaceId: defaultId,
activeWorkspaceId: defaultId,
order: [defaultId],
byId: {
[defaultId]: {
id: defaultId,
name: "Default",
updatedAt: Date.now(),
settings: {} // Default workspace has no overrides
}
}
};
return this.plugin.settings.workspaces;
}
// Ensure default workspace invariants
public ensureDefaultWorkspaceInvariant(): void {
const config = this.getWorkspacesConfig();
// Ensure default workspace exists
if (!config.defaultWorkspaceId || !config.byId[config.defaultWorkspaceId]) {
const defaultId = this.generateId();
config.defaultWorkspaceId = defaultId;
config.byId[defaultId] = {
id: defaultId,
name: "Default",
updatedAt: Date.now(),
settings: {}
};
if (!config.order.includes(defaultId)) {
config.order.unshift(defaultId);
}
}
// Ensure default workspace has no overrides
const defaultWs = config.byId[config.defaultWorkspaceId];
if (defaultWs.settings && Object.keys(defaultWs.settings).length > 0) {
// Merge any overrides into global settings and clear
this.mergeIntoGlobal(defaultWs.settings);
defaultWs.settings = {};
}
// Ensure active workspace exists
if (!config.activeWorkspaceId || !config.byId[config.activeWorkspaceId]) {
config.activeWorkspaceId = config.defaultWorkspaceId;
}
}
// Merge workspace overrides into global settings
private mergeIntoGlobal(overrides: WorkspaceOverrides): void {
for (const key of Object.keys(overrides)) {
if (WORKSPACE_SCOPED_KEYS.includes(key as any)) {
(this.plugin.settings as any)[key] = structuredClone(overrides[key as keyof WorkspaceOverrides]);
}
}
}
// Generate effective settings for a workspace
public getEffectiveSettings(workspaceId?: string): EffectiveSettings {
const config = this.getWorkspacesConfig();
const id = workspaceId || config.activeWorkspaceId || config.defaultWorkspaceId;
// Return from cache if available
if (this.effectiveCache.has(id)) {
return this.effectiveCache.get(id)!;
}
// Build effective settings
const workspace = config.byId[id];
if (!workspace) {
// Fallback to default if workspace doesn't exist
return this.getEffectiveSettings(config.defaultWorkspaceId);
}
// Start with global settings
const effective: EffectiveSettings = { ...this.plugin.settings };
// Apply workspace overrides if not default
if (id !== config.defaultWorkspaceId && workspace.settings) {
for (const key of WORKSPACE_SCOPED_KEYS) {
if (workspace.settings[key] !== undefined) {
effective[key] = structuredClone(workspace.settings[key]);
}
}
}
// Cache the result
this.effectiveCache.set(id, effective);
return effective;
}
// Calculate overrides from effective settings
private toOverrides(effective: EffectiveSettings): WorkspaceOverrides {
const overrides: WorkspaceOverrides = {};
for (const key of WORKSPACE_SCOPED_KEYS) {
const effValue = effective[key];
const globalValue = (this.plugin.settings as any)[key];
if (JSON.stringify(effValue) !== JSON.stringify(globalValue)) {
overrides[key] = structuredClone(effValue);
}
}
return overrides;
}
// Normalize overrides (remove ones identical to global)
private normalizeOverrides(): void {
const config = this.getWorkspacesConfig();
for (const id of config.order) {
if (id === config.defaultWorkspaceId) continue;
const workspace = config.byId[id];
if (!workspace.settings) continue;
for (const key of WORKSPACE_SCOPED_KEYS) {
if (workspace.settings[key] !== undefined) {
const globalValue = (this.plugin.settings as any)[key];
if (JSON.stringify(workspace.settings[key]) === JSON.stringify(globalValue)) {
delete workspace.settings[key];
}
}
}
}
}
// Clear the effective cache
public clearCache(): void {
this.effectiveCache.clear();
}
// Get all workspaces
public getAllWorkspaces(): WorkspaceData[] {
const config = this.getWorkspacesConfig();
return config.order.map(id => config.byId[id]).filter(ws => ws !== undefined);
}
// Get workspace by ID
public getWorkspace(id: string): WorkspaceData | undefined {
const config = this.getWorkspacesConfig();
return config.byId[id];
}
// Get active workspace
public getActiveWorkspace(): WorkspaceData {
const config = this.getWorkspacesConfig();
const activeId = config.activeWorkspaceId || config.defaultWorkspaceId;
return config.byId[activeId] || config.byId[config.defaultWorkspaceId];
}
// Set active workspace
public async setActiveWorkspace(workspaceId: string): Promise<void> {
const config = this.getWorkspacesConfig();
if (!config.byId[workspaceId]) {
new Notice(`Workspace not found. Using default workspace.`);
workspaceId = config.defaultWorkspaceId;
}
if (config.activeWorkspaceId === workspaceId) {
return; // Already active
}
config.activeWorkspaceId = workspaceId;
this.clearCache();
await this.plugin.saveSettings();
emitWorkspaceSwitched(this.app, workspaceId);
}
// Create new workspace (cloned from current or default)
public async createWorkspace(name: string, baseWorkspaceId?: string): Promise<WorkspaceData> {
const config = this.getWorkspacesConfig();
const id = this.generateId();
// Use current active workspace as base if not specified
const baseId = baseWorkspaceId || config.activeWorkspaceId || config.defaultWorkspaceId;
const baseWorkspace = config.byId[baseId];
// Clone settings from base workspace (if not default)
let settings: WorkspaceOverrides = {};
if (baseId !== config.defaultWorkspaceId && baseWorkspace?.settings) {
settings = structuredClone(baseWorkspace.settings);
} else if (baseId === config.defaultWorkspaceId) {
// Creating from default means starting with current global values as-is
settings = {};
}
const newWorkspace: WorkspaceData = {
id,
name,
updatedAt: Date.now(),
settings
};
config.byId[id] = newWorkspace;
config.order.push(id);
await this.plugin.saveSettings();
emitWorkspaceCreated(this.app, id, baseId);
return newWorkspace;
}
// Delete workspace
public async deleteWorkspace(workspaceId: string): Promise<void> {
const config = this.getWorkspacesConfig();
// Cannot delete default workspace
if (workspaceId === config.defaultWorkspaceId) {
new Notice("Cannot delete the default workspace");
return;
}
if (!config.byId[workspaceId]) {
return; // Already doesn't exist
}
// Remove from config
delete config.byId[workspaceId];
const orderIndex = config.order.indexOf(workspaceId);
if (orderIndex !== -1) {
config.order.splice(orderIndex, 1);
}
// If this was the active workspace, switch to default
if (config.activeWorkspaceId === workspaceId) {
config.activeWorkspaceId = config.defaultWorkspaceId;
emitWorkspaceSwitched(this.app, config.defaultWorkspaceId);
}
this.clearCache();
await this.plugin.saveSettings();
emitWorkspaceDeleted(this.app, workspaceId);
}
// Rename workspace
public async renameWorkspace(workspaceId: string, newName: string): Promise<void> {
const config = this.getWorkspacesConfig();
const workspace = config.byId[workspaceId];
if (!workspace) {
return;
}
workspace.name = newName;
workspace.updatedAt = Date.now();
await this.plugin.saveSettings();
emitWorkspaceRenamed(this.app, workspaceId, newName);
}
// Save overrides for a workspace
public async saveOverrides(workspaceId: string, effective: EffectiveSettings): Promise<void> {
const config = this.getWorkspacesConfig();
// Cannot save overrides to default workspace
if (workspaceId === config.defaultWorkspaceId) {
// For default, write directly to global settings
for (const key of WORKSPACE_SCOPED_KEYS) {
if (effective[key] !== undefined) {
(this.plugin.settings as any)[key] = structuredClone(effective[key]);
}
}
await this.plugin.saveSettings();
emit(this.app, Events.SETTINGS_CHANGED);
return;
}
const workspace = config.byId[workspaceId];
if (!workspace) {
return;
}
// Calculate overrides
const overrides = this.toOverrides(effective);
const changedKeys = Object.keys(overrides);
workspace.settings = overrides;
workspace.updatedAt = Date.now();
this.clearCache();
await this.plugin.saveSettings();
emitWorkspaceOverridesSaved(this.app, workspaceId, changedKeys);
emit(this.app, Events.SETTINGS_CHANGED);
}
// Save overrides quietly without triggering SETTINGS_CHANGED event
public async saveOverridesQuietly(workspaceId: string, effective: EffectiveSettings): Promise<void> {
const config = this.getWorkspacesConfig();
// Cannot save overrides to default workspace
if (workspaceId === config.defaultWorkspaceId) {
// For default, write directly to global settings
for (const key of WORKSPACE_SCOPED_KEYS) {
if (effective[key] !== undefined) {
(this.plugin.settings as any)[key] = structuredClone(effective[key]);
}
}
await this.plugin.saveSettings();
return;
}
const workspace = config.byId[workspaceId];
if (!workspace) {
return;
}
// Calculate overrides
const overrides = this.toOverrides(effective);
workspace.settings = overrides;
workspace.updatedAt = Date.now();
this.clearCache();
await this.plugin.saveSettings();
// Don't emit events to avoid triggering reload loops
}
// Reset workspace overrides
public async resetOverrides(workspaceId: string): Promise<void> {
const config = this.getWorkspacesConfig();
// Cannot reset default workspace
if (workspaceId === config.defaultWorkspaceId) {
return;
}
const workspace = config.byId[workspaceId];
if (!workspace) {
return;
}
workspace.settings = {};
workspace.updatedAt = Date.now();
this.clearCache();
await this.plugin.saveSettings();
emitWorkspaceReset(this.app, workspaceId);
emit(this.app, Events.SETTINGS_CHANGED);
}
// Set default workspace (change which one is default)
public async setDefaultWorkspace(workspaceId: string): Promise<void> {
const config = this.getWorkspacesConfig();
if (!config.byId[workspaceId]) {
return;
}
if (config.defaultWorkspaceId === workspaceId) {
return; // Already default
}
// The old default workspace needs to get current global settings as overrides
// The new default workspace's overrides become the new global settings
const oldDefaultId = config.defaultWorkspaceId;
const newDefaultWorkspace = config.byId[workspaceId];
// Save current global as overrides for old default
const currentGlobalAsOverrides: WorkspaceOverrides = {};
for (const key of WORKSPACE_SCOPED_KEYS) {
const globalValue = (this.plugin.settings as any)[key];
if (globalValue !== undefined) {
currentGlobalAsOverrides[key] = structuredClone(globalValue);
}
}
// Apply new default's overrides to global
if (newDefaultWorkspace.settings) {
this.mergeIntoGlobal(newDefaultWorkspace.settings);
}
// Set old default's overrides
config.byId[oldDefaultId].settings = currentGlobalAsOverrides;
// Clear new default's overrides
newDefaultWorkspace.settings = {};
// Update default ID
config.defaultWorkspaceId = workspaceId;
// Normalize all overrides
this.normalizeOverrides();
this.clearCache();
await this.plugin.saveSettings();
emitDefaultWorkspaceChanged(this.app, workspaceId);
emit(this.app, Events.SETTINGS_CHANGED);
}
// Generate unique ID
private generateId(): string {
return `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
// Migrate from v1 to v2
public async migrateToV2(): Promise<void> {
if (this.plugin.settings.workspaces?.version === 2) {
return; // Already v2
}
// Initialize v2 structure
this.initializeWorkspaces();
this.ensureDefaultWorkspaceInvariant();
// If there were v1 workspaces, migrate them
// (This is a placeholder - implement based on actual v1 structure)
await this.plugin.saveSettings();
}
// Reorder workspaces
public async reorderWorkspaces(newOrder: string[]): Promise<void> {
const config = this.getWorkspacesConfig();
// Validate that all IDs exist and default is first
const validOrder = newOrder.filter(id => config.byId[id]);
// Ensure default is always first
const defaultIndex = validOrder.indexOf(config.defaultWorkspaceId);
if (defaultIndex > 0) {
validOrder.splice(defaultIndex, 1);
validOrder.unshift(config.defaultWorkspaceId);
} else if (defaultIndex === -1) {
validOrder.unshift(config.defaultWorkspaceId);
}
config.order = validOrder;
await this.plugin.saveSettings();
}
// Check if a workspace is the default
public isDefaultWorkspace(workspaceId: string): boolean {
const config = this.getWorkspacesConfig();
return workspaceId === config.defaultWorkspaceId;
}
// Export workspace configuration
public exportWorkspace(workspaceId: string): string | null {
const workspace = this.getWorkspace(workspaceId);
if (!workspace) return null;
const exportData = {
name: workspace.name,
settings: workspace.settings,
exportedAt: Date.now(),
version: 1
};
return JSON.stringify(exportData, null, 2);
}
// Import workspace configuration
public async importWorkspace(jsonData: string, name?: string): Promise<WorkspaceData | null> {
try {
const importData = JSON.parse(jsonData);
const workspaceName = name || importData.name || "Imported Workspace";
const settings = importData.settings || {};
const newWorkspace = await this.createWorkspace(workspaceName);
newWorkspace.settings = settings;
await this.plugin.saveSettings();
return newWorkspace;
} catch (e) {
new Notice("Failed to import workspace configuration");
console.error("Workspace import error:", e);
return null;
}
}
}

View file

@ -1,3 +1,4 @@
// Deprecated - use WorkspaceData from types/workspace.ts instead
export interface Workspace {
id: string;
name: string;
@ -14,7 +15,7 @@ export interface WorkspaceSettings {
export interface V2ViewState {
currentWorkspace: string;
selectedProject?: string;
selectedProject?: string | null;
viewMode: 'list' | 'kanban' | 'tree' | 'calendar';
searchQuery?: string;
filters?: any;

View file

@ -0,0 +1,94 @@
export interface WorkspaceData {
id: string;
name: string;
color?: string;
updatedAt: number;
order?: number;
settings: WorkspaceOverrides; // Empty for Default workspace
}
export interface WorkspaceOverrides {
// View display settings
filters?: any;
sort?: any;
group?: any;
columns?: any;
viewMode?: string;
// Calendar settings
calendar?: any;
// Kanban settings
kanban?: any;
// Gantt settings
gantt?: any;
// Other display-related settings
displayOptions?: any;
viewConfiguration?: any;
taskListDisplayOption?: any;
forecastOption?: any;
customProjectGroupsAndNames?: any;
tagCustomOrder?: any;
// V2 filter state per view
v2FilterState?: Record<string, {
filters?: any;
searchQuery?: string;
selectedProject?: string | null;
advancedFilter?: any;
viewMode?: string;
}>;
}
export interface WorkspacesConfig {
version: number;
defaultWorkspaceId: string;
activeWorkspaceId?: string;
order: string[]; // Workspace IDs in display order
byId: Record<string, WorkspaceData>;
}
export interface EffectiveSettings {
// Merged result of global + workspace overrides
[key: string]: any;
}
// Keys that can be overridden per workspace
export const WORKSPACE_SCOPED_KEYS = [
'filters',
'sort',
'group',
'columns',
'viewMode',
'calendar',
'kanban',
'gantt',
'displayOptions',
'viewConfiguration',
'taskListDisplayOption',
'forecastOption',
'customProjectGroupsAndNames',
'tagCustomOrder',
'v2FilterState'
] as const;
export type WorkspaceScopedKey = typeof WORKSPACE_SCOPED_KEYS[number];
// Global-only keys (cannot be overridden)
export const GLOBAL_ONLY_KEYS = [
'autoRun',
'lang',
'experimental',
'appearance',
'hotkeys',
'quickCapture',
'workflow',
'habit',
'reward',
'integrations',
'editorExtensions'
] as const;
export type GlobalOnlyKey = typeof GLOBAL_ONLY_KEYS[number];

View file

@ -246,6 +246,9 @@ export default class TaskProgressBarPlugin extends Plugin {
// Setting tab
settingTab: TaskProgressBarSettingTab;
// Workspace manager instance
workspaceManager?: import("./experimental/v2/managers/WorkspaceManager").WorkspaceManager;
// Task Genius Icon manager instance
taskGeniusIconManager: TaskGeniusIconManager;
@ -286,6 +289,12 @@ export default class TaskProgressBarPlugin extends Plugin {
// Initialize global suggest manager
this.globalSuggestManager = new SuggestManager(this.app, this);
// Initialize workspace manager
const { WorkspaceManager } = await import("./experimental/v2/managers/WorkspaceManager");
this.workspaceManager = new WorkspaceManager(this);
await this.workspaceManager.migrateToV2();
this.workspaceManager.ensureDefaultWorkspaceInvariant();
// Initialize URI handler
this.uriHandler = new ObsidianUriHandler(this);
this.uriHandler.register();

View file

@ -7,6 +7,9 @@ import {
Platform,
requireApiVersion,
debounce,
Menu,
Modal,
Notice,
} from "obsidian";
import TaskProgressBarPlugin from ".";
@ -42,6 +45,7 @@ import { renderMcpIntegrationSettingsTab } from "./components/features/settings/
import { IframeModal } from "@/components/ui/modals/IframeModal";
import { renderTaskTimerSettingTab } from "./components/features/settings/tabs/TaskTimerSettingsTab";
import { renderBasesSettingsTab } from "./components/features/settings/tabs/BasesSettingsTab";
import { WorkspaceData } from "./experimental/v2/types/workspace";
export class TaskProgressBarSettingTab extends PluginSettingTab {
plugin: TaskProgressBarPlugin;
@ -196,6 +200,12 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
icon: "layout",
category: "integration",
},
{
id: "workspaces",
name: t("Workspaces"),
icon: "layers",
category: "integration",
},
{
id: "beta-test",
name: t("Beta Features"),
@ -675,6 +685,10 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
this.displayBasesSettings(basesSection);
}
// Workspaces Tab
const workspacesSection = this.createTabSection("workspaces");
this.displayWorkspacesSettings(workspacesSection);
// Beta Test Tab
const betaTestSection = this.createTabSection("beta-test");
this.displayBetaTestSettings(betaTestSection);
@ -734,6 +748,8 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
return `${base}/bases-support`;
case "desktop-integration":
return `${base}/bases-support`;
case "workspaces":
return `${base}/workspaces`;
case "beta-test":
return `${base}/getting-started`;
case "experimental":
@ -848,6 +864,10 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
renderBetaTestSettingsTab(this, containerEl);
}
private displayWorkspacesSettings(containerEl: HTMLElement): void {
this.renderWorkspacesSettingsTab(containerEl);
}
// private displayExperimentalSettings(containerEl: HTMLElement): void {
// this.renderExperimentalSettingsTab(containerEl);
// }
@ -856,6 +876,370 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
renderTaskTimerSettingTab(this, containerEl);
}
private renderWorkspacesSettingsTab(containerEl: HTMLElement): void {
// Create workspaces settings section
const workspacesSection = containerEl.createDiv();
workspacesSection.addClass("workspaces-settings-section");
// Section header
const headerEl = workspacesSection.createEl("h2");
headerEl.setText(t("Workspace Management"));
headerEl.addClass("workspaces-section-heading");
// Description
const descEl = workspacesSection.createDiv();
descEl.addClass("workspaces-description");
descEl.setText(
t("Manage workspaces to organize different contexts with their own settings and filters.")
);
if (!this.plugin.workspaceManager) {
const warningEl = workspacesSection.createDiv();
warningEl.addClass("workspaces-warning");
warningEl.setText(t("Workspace manager is not available."));
return;
}
// Current workspace info
const currentWorkspace = this.plugin.workspaceManager.getActiveWorkspace();
const isDefault = this.plugin.workspaceManager.isDefaultWorkspace(currentWorkspace.id);
new Setting(workspacesSection)
.setName(t("Current Workspace"))
.setDesc(
`${currentWorkspace.name}${isDefault ? " (" + t("Default") + ")" : ""}`
)
.addButton((button) => {
button
.setButtonText(t("Switch Workspace"))
.onClick(() => {
this.showWorkspaceSelector();
});
});
// Workspace list
const allWorkspaces = this.plugin.workspaceManager.getAllWorkspaces();
const workspaceListEl = workspacesSection.createDiv();
workspaceListEl.addClass("workspace-list");
const listHeaderEl = workspaceListEl.createEl("h3");
listHeaderEl.setText(t("All Workspaces"));
allWorkspaces.forEach((workspace) => {
const workspaceItemEl = workspaceListEl.createDiv();
workspaceItemEl.addClass("workspace-item");
const isCurrentActive = workspace.id === currentWorkspace.id;
const isDefaultWs = this.plugin.workspaceManager!.isDefaultWorkspace(workspace.id);
if (isCurrentActive) {
workspaceItemEl.addClass("workspace-item-active");
}
new Setting(workspaceItemEl)
.setName(workspace.name)
.setDesc(
isDefaultWs
? t("Default workspace")
: t("Last updated: {{date}}", {
date: new Date(workspace.updatedAt).toLocaleDateString(),
})
)
.addButton((button) => {
if (isCurrentActive) {
button.setButtonText(t("Active")).setDisabled(true);
} else {
button.setButtonText(t("Switch")).onClick(async () => {
await this.plugin.workspaceManager!.setActiveWorkspace(
workspace.id
);
this.display();
});
}
})
.addButton((button) => {
button.setIcon("edit").setTooltip(t("Rename")).onClick(() => {
this.showRenameWorkspaceDialog(workspace);
});
})
.addButton((button) => {
if (isDefaultWs) {
button.setDisabled(true);
} else {
button
.setIcon("trash")
.setTooltip(t("Delete"))
.onClick(() => {
this.showDeleteWorkspaceDialog(workspace);
});
}
});
});
// Create new workspace button
new Setting(workspacesSection)
.setName(t("Create New Workspace"))
.setDesc(t("Create a new workspace with custom settings"))
.addButton((button) => {
button
.setButtonText(t("Create"))
.setCta()
.onClick(() => {
this.showCreateWorkspaceDialog();
});
});
}
private showWorkspaceSelector() {
if (!this.plugin.workspaceManager) return;
const menu = new Menu();
const workspaces = this.plugin.workspaceManager.getAllWorkspaces();
const currentWorkspace = this.plugin.workspaceManager.getActiveWorkspace();
workspaces.forEach((workspace) => {
menu.addItem((item) => {
item.setTitle(workspace.name)
.setIcon("layers")
.onClick(async () => {
await this.plugin.workspaceManager!.setActiveWorkspace(
workspace.id
);
this.display();
});
if (workspace.id === currentWorkspace.id) {
item.setChecked(true);
}
});
});
menu.showAtPosition({ x: 0, y: 0 });
}
private showCreateWorkspaceDialog() {
if (!this.plugin.workspaceManager) return;
class CreateWorkspaceModal extends Modal {
private nameInput: HTMLInputElement;
private baseSelect: HTMLSelectElement;
constructor(
private plugin: TaskProgressBarPlugin,
private onCreated: () => void
) {
super(plugin.app);
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h2", { text: t("Create New Workspace") });
new Setting(contentEl)
.setName(t("Workspace Name"))
.addText((text) => {
text.setPlaceholder(t("Enter workspace name"));
this.nameInput = text.inputEl;
});
new Setting(contentEl)
.setName(t("Copy Settings From"))
.addDropdown((dropdown) => {
dropdown.addOption("", t("Default settings"));
this.plugin.workspaceManager
?.getAllWorkspaces()
.forEach((ws) => {
dropdown.addOption(ws.id, ws.name);
});
this.baseSelect = dropdown.selectEl;
});
const buttonContainer = contentEl.createDiv({
cls: "modal-button-container",
});
const createButton = buttonContainer.createEl("button", {
text: t("Create"),
cls: "mod-cta",
});
const cancelButton = buttonContainer.createEl("button", {
text: t("Cancel"),
});
createButton.addEventListener("click", async () => {
const name = this.nameInput.value.trim();
const baseId = this.baseSelect.value || undefined;
if (name && this.plugin.workspaceManager) {
await this.plugin.workspaceManager.createWorkspace(
name,
baseId
);
new Notice(t("Workspace created"));
this.onCreated();
this.close();
} else {
new Notice(t("Please enter a workspace name"));
}
});
cancelButton.addEventListener("click", () => {
this.close();
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
new CreateWorkspaceModal(this.plugin, () => {
this.display();
}).open();
}
private showRenameWorkspaceDialog(workspace: WorkspaceData) {
if (!this.plugin.workspaceManager) return;
class RenameWorkspaceModal extends Modal {
private nameInput: HTMLInputElement;
constructor(
private plugin: TaskProgressBarPlugin,
private workspace: WorkspaceData,
private onRenamed: () => void
) {
super(plugin.app);
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h2", { text: t("Rename Workspace") });
new Setting(contentEl).setName(t("New Name")).addText((text) => {
text
.setValue(this.workspace.name)
.setPlaceholder(t("Enter new name"));
this.nameInput = text.inputEl;
});
const buttonContainer = contentEl.createDiv({
cls: "modal-button-container",
});
const renameButton = buttonContainer.createEl("button", {
text: t("Rename"),
cls: "mod-cta",
});
const cancelButton = buttonContainer.createEl("button", {
text: t("Cancel"),
});
renameButton.addEventListener("click", async () => {
const newName = this.nameInput.value.trim();
if (
newName &&
newName !== this.workspace.name &&
this.plugin.workspaceManager
) {
await this.plugin.workspaceManager.renameWorkspace(
this.workspace.id,
newName
);
new Notice(t("Workspace renamed"));
this.onRenamed();
this.close();
} else {
new Notice(t("Please enter a different name"));
}
});
cancelButton.addEventListener("click", () => {
this.close();
});
this.nameInput.focus();
this.nameInput.select();
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
new RenameWorkspaceModal(this.plugin, workspace, () => {
this.display();
}).open();
}
private showDeleteWorkspaceDialog(workspace: WorkspaceData) {
if (!this.plugin.workspaceManager) return;
class DeleteWorkspaceModal extends Modal {
constructor(
private plugin: TaskProgressBarPlugin,
private workspace: WorkspaceData,
private onDeleted: () => void
) {
super(plugin.app);
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h2", { text: t("Delete Workspace") });
contentEl.createEl("p", {
text: t(
'Are you sure you want to delete "{{name}}"? This action cannot be undone.',
{ name: this.workspace.name }
),
});
const buttonContainer = contentEl.createDiv({
cls: "modal-button-container",
});
const deleteButton = buttonContainer.createEl("button", {
text: t("Delete"),
cls: "mod-warning",
});
const cancelButton = buttonContainer.createEl("button", {
text: t("Cancel"),
});
deleteButton.addEventListener("click", async () => {
if (this.plugin.workspaceManager) {
await this.plugin.workspaceManager.deleteWorkspace(
this.workspace.id
);
new Notice(t("Workspace deleted"));
this.onDeleted();
this.close();
}
});
cancelButton.addEventListener("click", () => {
this.close();
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
new DeleteWorkspaceModal(this.plugin, workspace, () => {
this.display();
}).open();
}
private renderExperimentalSettingsTab(containerEl: HTMLElement): void {
// Create experimental settings section
const experimentalSection = containerEl.createDiv();