refactor(v2): decompose TaskViewV2 into stateless manager classes

Split monolithic TaskViewV2 (3207 lines) into 6 focused managers:
- FluentDataManager: data loading and filtering
- FluentLayoutManager: layout, sidebar, details panel, action buttons
- FluentComponentManager: view component orchestration
- FluentActionHandlers: user action handling
- FluentGestureManager: mobile gesture support
- FluentWorkspaceStateManager: state persistence

Key fixes:
- Restore missing DOM layers (tg-v2-main-container, tg-v2-content-wrapper)
- Add action buttons (Details, Capture, Filter, Reset Filter) to header
- Fix project selection to update filter state and trigger events
- Add filter-changed event listener for cross-view filtering
- Prevent double rendering by centralizing updateView calls

Architecture:
- TaskViewV2 is now single source of truth for all state
- Managers are stateless executors that communicate via callbacks
- State flows: Manager executes → Callback → TaskViewV2 updates state
This commit is contained in:
Quorafind 2025-09-30 11:21:31 +08:00
parent 148f1633a8
commit 68247f03be
9 changed files with 3330 additions and 2995 deletions

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,545 @@
import { Component, Menu, Notice, TFile } from "obsidian";
import { App } from "obsidian";
import TaskProgressBarPlugin from "@/index";
import { Task } from "@/types/task";
import { QuickCaptureModal } from "@/components/features/quick-capture/modals/QuickCaptureModal";
import { ConfirmModal } from "@/components/ui/modals/ConfirmModal";
import { createTaskCheckbox } from "@/components/features/task/view/details";
import { emitTaskSelected } from "../events/ui-event";
import { t } from "@/translations/helper";
import { ViewMode } from "../components/V2TopNavigation";
/**
* FluentActionHandlers - Handles all user actions and task operations
*
* Responsibilities:
* - Task selection and deselection
* - Task completion toggling
* - Task updates (status, metadata, etc.)
* - Task context menus
* - Task deletion (with children support)
* - Navigation actions (view switch, project select, search)
* - Settings and UI actions
*/
export class FluentActionHandlers extends Component {
// Task selection state
private currentSelectedTaskId: string | null = null;
private lastToggleTimestamp = 0;
// Callbacks
private onTaskSelectionChanged?: (task: Task | null) => void;
private onTaskUpdated?: (taskId: string, updatedTask: Task) => void;
private onTaskDeleted?: (taskId: string, deleteChildren: boolean) => void;
private onNavigateToView?: (viewId: string) => void;
private onSearchQueryChanged?: (query: string) => void;
private onProjectSelected?: (projectId: string) => void;
private onViewModeChanged?: (mode: ViewMode) => void;
private showDetailsPanel?: (task: Task) => void;
private toggleDetailsVisibility?: (visible: boolean) => void;
private getIsDetailsVisible?: () => boolean;
constructor(
private app: App,
private plugin: TaskProgressBarPlugin,
private getWorkspaceId: () => string,
private useSideLeaves: () => boolean
) {
super();
}
/**
* Set callbacks for action results
*/
setCallbacks(callbacks: {
onTaskSelectionChanged?: (task: Task | null) => void;
onTaskUpdated?: (taskId: string, updatedTask: Task) => void;
onTaskDeleted?: (taskId: string, deleteChildren: boolean) => void;
onNavigateToView?: (viewId: string) => void;
onSearchQueryChanged?: (query: string) => void;
onProjectSelected?: (projectId: string) => void;
onViewModeChanged?: (mode: ViewMode) => void;
showDetailsPanel?: (task: Task) => void;
toggleDetailsVisibility?: (visible: boolean) => void;
getIsDetailsVisible?: () => boolean;
}): void {
this.onTaskSelectionChanged = callbacks.onTaskSelectionChanged;
this.onTaskUpdated = callbacks.onTaskUpdated;
this.onTaskDeleted = callbacks.onTaskDeleted;
this.onNavigateToView = callbacks.onNavigateToView;
this.onSearchQueryChanged = callbacks.onSearchQueryChanged;
this.onProjectSelected = callbacks.onProjectSelected;
this.onViewModeChanged = callbacks.onViewModeChanged;
this.showDetailsPanel = callbacks.showDetailsPanel;
this.toggleDetailsVisibility = callbacks.toggleDetailsVisibility;
this.getIsDetailsVisible = callbacks.getIsDetailsVisible;
}
/**
* Handle task selection
*/
handleTaskSelection(task: Task | null): void {
// Emit cross-view selection when using side leaves
if (this.useSideLeaves()) {
emitTaskSelected(this.app, {
taskId: task?.id ?? null,
origin: "main",
workspaceId: this.getWorkspaceId(),
});
}
if (task) {
const now = Date.now();
const timeSinceLastToggle = now - this.lastToggleTimestamp;
if (this.currentSelectedTaskId !== task.id) {
this.currentSelectedTaskId = task.id;
this.showDetailsPanel?.(task);
const isDetailsVisible = this.getIsDetailsVisible?.() ?? false;
if (!isDetailsVisible) {
this.toggleDetailsVisibility?.(true);
}
this.lastToggleTimestamp = now;
this.onTaskSelectionChanged?.(task);
return;
}
if (timeSinceLastToggle > 150) {
const isDetailsVisible = this.getIsDetailsVisible?.() ?? false;
this.toggleDetailsVisibility?.(!isDetailsVisible);
this.lastToggleTimestamp = now;
}
} else {
this.toggleDetailsVisibility?.(false);
this.currentSelectedTaskId = null;
this.onTaskSelectionChanged?.(null);
}
}
/**
* Toggle task completion status
*/
async toggleTaskCompletion(task: Task): Promise<void> {
if (!this.plugin.writeAPI) {
new Notice("WriteAPI not available");
return;
}
const updatedTask = { ...task, completed: !task.completed };
if (updatedTask.completed) {
updatedTask.metadata.completedDate = Date.now();
const completedMark = (
this.plugin.settings.taskStatuses.completed || "x"
).split("|")[0];
if (updatedTask.status !== completedMark) {
updatedTask.status = completedMark;
}
} else {
updatedTask.metadata.completedDate = undefined;
const notStartedMark =
this.plugin.settings.taskStatuses.notStarted || " ";
if (this.isCompletedMark(updatedTask.status)) {
updatedTask.status = notStartedMark;
}
}
await this.handleTaskUpdate(task, updatedTask);
}
/**
* Handle task update
*/
async handleTaskUpdate(
originalTask: Task,
updatedTask: Task
): Promise<void> {
if (!this.plugin.writeAPI) {
console.error("WriteAPI not available");
return;
}
try {
const updates = this.extractChangedFields(
originalTask,
updatedTask
);
const writeResult = await this.plugin.writeAPI.updateTask({
taskId: originalTask.id,
updates: updates,
});
if (!writeResult.success) {
throw new Error(writeResult.error || "Failed to update task");
}
const updated = writeResult.task || updatedTask;
// Notify about task update
this.onTaskUpdated?.(originalTask.id, updated);
new Notice(t("Task updated"));
} catch (error) {
console.error("Failed to update task:", error);
new Notice("Failed to update task");
}
}
/**
* Handle kanban task status update
*/
async handleKanbanTaskStatusUpdate(
task: Task,
newStatusMark: string
): Promise<void> {
const isCompleted = this.isCompletedMark(newStatusMark);
const completedDate = isCompleted ? Date.now() : undefined;
if (task.status !== newStatusMark || task.completed !== isCompleted) {
await this.handleTaskUpdate(task, {
...task,
status: newStatusMark,
completed: isCompleted,
metadata: {
...task.metadata,
completedDate: completedDate,
},
});
}
}
/**
* Show task context menu
*/
handleTaskContextMenu(event: MouseEvent, task: Task): void {
const menu = new Menu();
menu.addItem((item) => {
item.setTitle(t("Complete"));
item.setIcon("check-square");
item.onClick(() => {
this.toggleTaskCompletion(task);
});
})
.addItem((item) => {
item.setIcon("square-pen");
item.setTitle(t("Switch status"));
const submenu = item.setSubmenu();
// Get unique statuses from taskStatusMarks
const statusMarks = this.plugin.settings.taskStatusMarks;
const uniqueStatuses = new Map<string, string>();
// Build a map of unique mark -> status name to avoid duplicates
for (const status of Object.keys(statusMarks)) {
const mark =
statusMarks[status as keyof typeof statusMarks];
if (!Array.from(uniqueStatuses.values()).includes(mark)) {
uniqueStatuses.set(status, mark);
}
}
// Create menu items from unique statuses
for (const [status, mark] of uniqueStatuses) {
submenu.addItem((item) => {
item.titleEl.createEl(
"span",
{
cls: "status-option-checkbox",
},
(el) => {
createTaskCheckbox(mark, task, el);
}
);
item.titleEl.createEl("span", {
cls: "status-option",
text: status,
});
item.onClick(async () => {
const willComplete = this.isCompletedMark(mark);
const updatedTask = {
...task,
status: mark,
completed: willComplete,
};
if (!task.completed && willComplete) {
updatedTask.metadata.completedDate = Date.now();
} else if (task.completed && !willComplete) {
updatedTask.metadata.completedDate = undefined;
}
await this.handleTaskUpdate(task, updatedTask);
});
});
}
})
.addSeparator()
.addItem((item) => {
item.setTitle(t("Edit"));
item.setIcon("pencil");
item.onClick(() => {
this.handleTaskSelection(task);
});
})
.addItem((item) => {
item.setTitle(t("Edit in File"));
item.setIcon("pencil");
item.onClick(() => {
this.editTask(task);
});
})
.addSeparator()
.addItem((item) => {
item.setTitle(t("Delete Task"));
item.setIcon("trash");
item.onClick(() => {
this.confirmAndDeleteTask(event, task);
});
});
menu.showAtMouseEvent(event);
}
/**
* Edit task in file
*/
private async editTask(task: Task): Promise<void> {
const file = this.app.vault.getFileByPath(task.filePath);
if (!(file instanceof TFile)) return;
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file, {
eState: { line: task.line },
});
}
/**
* Confirm and delete task (with children option)
*/
private confirmAndDeleteTask(event: MouseEvent, task: Task): void {
const hasChildren =
task.metadata &&
task.metadata.children &&
task.metadata.children.length > 0;
if (hasChildren) {
const menu = new Menu();
menu.addItem((item) => {
item.setTitle(t("Delete task only"));
item.setIcon("trash");
item.onClick(() => {
this.deleteTask(task, false);
});
});
menu.addItem((item) => {
item.setTitle(t("Delete task and all subtasks"));
item.setIcon("trash-2");
item.onClick(() => {
this.deleteTask(task, true);
});
});
menu.addSeparator();
menu.addItem((item) => {
item.setTitle(t("Cancel"));
});
menu.showAtMouseEvent(event);
} else {
const modal = new ConfirmModal(this.plugin, {
title: t("Delete Task"),
message: t("Are you sure you want to delete this task?"),
confirmText: t("Delete"),
cancelText: t("Cancel"),
onConfirm: (confirmed) => {
if (confirmed) {
this.deleteTask(task, false);
}
},
});
modal.open();
}
}
/**
* Delete task (and optionally children)
*/
private async deleteTask(
task: Task,
deleteChildren: boolean
): Promise<void> {
if (!this.plugin.writeAPI) {
console.error("WriteAPI not available for deleteTask");
new Notice(t("Failed to delete task"));
return;
}
try {
const result = await this.plugin.writeAPI.deleteTask({
taskId: task.id,
deleteChildren,
});
if (result.success) {
new Notice(t("Task deleted"));
// Notify about task deletion
this.onTaskDeleted?.(task.id, deleteChildren);
// Clear selection if deleted task was selected
if (this.currentSelectedTaskId === task.id) {
this.handleTaskSelection(null);
}
} else {
new Notice(
t("Failed to delete task") +
": " +
(result.error || "Unknown error")
);
}
} catch (error) {
console.error("Error deleting task:", error);
new Notice(
t("Failed to delete task") + ": " + (error as any).message
);
}
}
/**
* Handle navigation to a view or create new task
*/
handleNavigate(viewId: string): void {
if (viewId === "new-task") {
new QuickCaptureModal(this.app, this.plugin).open();
} else {
console.log(`[FluentAction] handleNavigate to ${viewId}`);
this.onNavigateToView?.(viewId);
}
}
/**
* Handle search query change
*/
handleSearch(query: string): void {
this.onSearchQueryChanged?.(query);
}
/**
* Handle project selection
*/
handleProjectSelect(projectId: string): void {
console.log(`[FluentAction] Project selected: ${projectId}`);
this.onProjectSelected?.(projectId);
}
/**
* Handle view mode change (list/tree/kanban/calendar)
*/
handleViewModeChange(mode: ViewMode): void {
this.onViewModeChanged?.(mode);
}
/**
* Handle settings button click
*/
handleSettingsClick(): void {
// Open Obsidian settings and navigate to the plugin tab
this.app.setting.open();
this.app.setting.openTabById(this.plugin.manifest.id);
}
/**
* Check if a status mark indicates completion
*/
private isCompletedMark(mark: string): boolean {
if (!mark) return false;
try {
const lower = mark.toLowerCase();
const completedCfg = String(
this.plugin.settings.taskStatuses?.completed || "x"
);
const completedSet = completedCfg
.split("|")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
return completedSet.includes(lower);
} catch (_) {
return false;
}
}
/**
* Extract changed fields from task update
*/
private extractChangedFields(
originalTask: Task,
updatedTask: Task
): Partial<Task> {
const changes: Partial<Task> = {};
// Check top-level fields
if (originalTask.content !== updatedTask.content) {
changes.content = updatedTask.content;
}
if (originalTask.completed !== updatedTask.completed) {
changes.completed = updatedTask.completed;
}
if (originalTask.status !== updatedTask.status) {
changes.status = updatedTask.status;
}
// Check metadata fields
const metadataChanges: Partial<typeof originalTask.metadata> = {};
let hasMetadataChanges = false;
const metadataFields = [
"priority",
"project",
"tags",
"context",
"dueDate",
"startDate",
"scheduledDate",
"completedDate",
"recurrence",
];
for (const field of metadataFields) {
const originalValue = (originalTask.metadata as any)?.[field];
const updatedValue = (updatedTask.metadata as any)?.[field];
if (field === "tags") {
const origTags = originalValue || [];
const updTags = updatedValue || [];
if (
origTags.length !== updTags.length ||
!origTags.every((t: string, i: number) => t === updTags[i])
) {
metadataChanges.tags = updTags;
hasMetadataChanges = true;
}
} else if (originalValue !== updatedValue) {
(metadataChanges as any)[field] = updatedValue;
hasMetadataChanges = true;
}
}
if (hasMetadataChanges) {
changes.metadata = metadataChanges as any;
}
return changes;
}
/**
* Get current selected task ID
*/
getCurrentSelectedTaskId(): string | null {
return this.currentSelectedTaskId;
}
/**
* Clear task selection
*/
clearSelection(): void {
this.currentSelectedTaskId = null;
this.onTaskSelectionChanged?.(null);
}
}

View file

@ -0,0 +1,880 @@
import { Component, setIcon } from "obsidian";
import { App } from "obsidian";
import TaskProgressBarPlugin from "@/index";
import { Task } from "@/types/task";
import { ContentComponent } from "@/components/features/task/view/content";
import { ForecastComponent } from "@/components/features/task/view/forecast";
import { TagsComponent } from "@/components/features/task/view/tags";
import { ProjectsComponent } from "@/components/features/task/view/projects";
import { ReviewComponent } from "@/components/features/task/view/review";
import { Habit } from "@/components/features/habit/habit";
import { CalendarComponent } from "@/components/features/calendar";
import { KanbanComponent } from "@/components/features/kanban/kanban";
import { GanttComponent } from "@/components/features/gantt/gantt";
import { ViewComponentManager } from "@/components/ui/behavior/ViewComponentManager";
import { TaskPropertyTwoColumnView } from "@/components/features/task/view/TaskPropertyTwoColumnView";
import {
getViewSettingOrDefault,
TwoColumnSpecificConfig,
} from "@/common/setting-definition";
import { filterTasks } from "@/utils/task/task-filter-utils";
import { RootFilterState } from "@/components/features/task/filter/ViewTaskFilter";
import { QuickCaptureModal } from "@/components/features/quick-capture/modals/QuickCaptureModal";
import { t } from "@/translations/helper";
import { ViewMode } from "../components/V2TopNavigation";
// View mode configuration for each view type
const VIEW_MODE_CONFIG: Record<string, ViewMode[]> = {
// Content-based views - support all modes
inbox: ["list", "tree", "kanban", "calendar"],
today: ["list", "tree", "kanban", "calendar"],
upcoming: ["list", "tree", "kanban", "calendar"],
flagged: ["list", "tree", "kanban", "calendar"],
projects: ["list", "tree", "kanban", "calendar"],
// Specialized views with limited or no modes
tags: [],
review: [],
forecast: [],
habit: [],
gantt: [],
calendar: [],
kanban: [],
};
/**
* FluentComponentManager - Manages view component lifecycle
*
* Responsibilities:
* - Initialize all view components (Content, Forecast, Tags, Calendar, Kanban, etc.)
* - Show/hide components based on active view
* - Switch between views (inbox, today, projects, calendar, etc.)
* - Render content with different view modes (list, tree, kanban, calendar)
* - Render loading, error, and empty states
*/
export class FluentComponentManager extends Component {
// View components
private contentComponent: ContentComponent;
private forecastComponent: ForecastComponent;
private tagsComponent: TagsComponent;
private projectsComponent: ProjectsComponent;
private reviewComponent: ReviewComponent;
private habitComponent: Habit;
private calendarComponent: CalendarComponent;
private kanbanComponent: KanbanComponent;
private ganttComponent: GanttComponent;
private viewComponentManager: ViewComponentManager;
// Two column view components
private twoColumnViewComponents: Map<string, TaskPropertyTwoColumnView> =
new Map();
// Legacy containers
private listContainer: HTMLElement;
private treeContainer: HTMLElement;
// Track currently visible component
private currentVisibleComponent: any = null;
// View handlers
private viewHandlers: {
onTaskSelected: (task: Task) => void;
onTaskCompleted: (task: Task) => void;
onTaskUpdate: (originalTask: Task, updatedTask: Task) => Promise<void>;
onTaskContextMenu: (event: MouseEvent, task: Task) => void;
onKanbanTaskStatusUpdate: (taskId: string, newStatusMark: string) => void;
};
constructor(
private app: App,
private plugin: TaskProgressBarPlugin,
private contentArea: HTMLElement,
private parentView: Component,
viewHandlers: {
onTaskSelected: (task: Task) => void;
onTaskCompleted: (task: Task) => void;
onTaskUpdate: (originalTask: Task, updatedTask: Task) => Promise<void>;
onTaskContextMenu: (event: MouseEvent, task: Task) => void;
onKanbanTaskStatusUpdate: (taskId: string, newStatusMark: string) => void;
}
) {
super();
this.viewHandlers = viewHandlers;
}
/**
* Initialize all view components
*/
initializeViewComponents(): void {
console.log("[FluentComponent] initializeViewComponents started");
// Initialize ViewComponentManager for special views
const viewHandlers = {
onTaskSelected: (task: Task) => this.viewHandlers.onTaskSelected(task),
onTaskCompleted: (task: Task) => this.viewHandlers.onTaskCompleted(task),
onTaskUpdate: async (originalTask: Task, updatedTask: Task) => {
await this.viewHandlers.onTaskUpdate(originalTask, updatedTask);
},
onTaskContextMenu: (event: MouseEvent, task: Task) =>
this.viewHandlers.onTaskContextMenu(event, task),
};
this.viewComponentManager = new ViewComponentManager(
this.parentView,
this.app,
this.plugin,
this.contentArea,
viewHandlers
);
this.parentView.addChild(this.viewComponentManager);
// Initialize ContentComponent (handles inbox, today, upcoming, flagged)
console.log("[FluentComponent] Creating ContentComponent");
this.contentComponent = new ContentComponent(
this.contentArea,
this.app,
this.plugin,
{
onTaskSelected: (task) => {
if (task) this.viewHandlers.onTaskSelected(task);
},
onTaskCompleted: (task) => {
if (task) this.viewHandlers.onTaskCompleted(task);
},
onTaskContextMenu: (event, task) => {
if (task) this.viewHandlers.onTaskContextMenu(event, task);
},
}
);
this.parentView.addChild(this.contentComponent);
this.contentComponent.load();
// Initialize ForecastComponent
this.forecastComponent = new ForecastComponent(
this.contentArea,
this.app,
this.plugin,
{
onTaskSelected: (task) => {
if (task) this.viewHandlers.onTaskSelected(task);
},
onTaskCompleted: (task) => {
if (task) this.viewHandlers.onTaskCompleted(task);
},
onTaskUpdate: async (originalTask, updatedTask) => {
if (originalTask && updatedTask) {
await this.viewHandlers.onTaskUpdate(originalTask, updatedTask);
}
},
onTaskContextMenu: (event, task) => {
if (task) this.viewHandlers.onTaskContextMenu(event, task);
},
}
);
this.parentView.addChild(this.forecastComponent);
this.forecastComponent.load();
// Initialize TagsComponent
this.tagsComponent = new TagsComponent(
this.contentArea,
this.app,
this.plugin,
{
onTaskSelected: (task) => {
if (task) this.viewHandlers.onTaskSelected(task);
},
onTaskCompleted: (task) => {
if (task) this.viewHandlers.onTaskCompleted(task);
},
onTaskUpdate: async (originalTask, updatedTask) => {
if (originalTask && updatedTask) {
await this.viewHandlers.onTaskUpdate(originalTask, updatedTask);
}
},
onTaskContextMenu: (event, task) => {
if (task) this.viewHandlers.onTaskContextMenu(event, task);
},
}
);
this.parentView.addChild(this.tagsComponent);
this.tagsComponent.load();
// Initialize ProjectsComponent
this.projectsComponent = new ProjectsComponent(
this.contentArea,
this.app,
this.plugin,
{
onTaskSelected: (task) => {
if (task) this.viewHandlers.onTaskSelected(task);
},
onTaskCompleted: (task) => {
if (task) this.viewHandlers.onTaskCompleted(task);
},
onTaskUpdate: async (originalTask, updatedTask) => {
if (originalTask && updatedTask) {
await this.viewHandlers.onTaskUpdate(originalTask, updatedTask);
}
},
onTaskContextMenu: (event, task) => {
if (task) this.viewHandlers.onTaskContextMenu(event, task);
},
}
);
this.parentView.addChild(this.projectsComponent);
this.projectsComponent.load();
// Initialize ReviewComponent
this.reviewComponent = new ReviewComponent(
this.contentArea,
this.app,
this.plugin,
{
onTaskSelected: (task) => {
if (task) this.viewHandlers.onTaskSelected(task);
},
onTaskCompleted: (task) => {
if (task) this.viewHandlers.onTaskCompleted(task);
},
onTaskUpdate: async (originalTask, updatedTask) => {
if (originalTask && updatedTask) {
await this.viewHandlers.onTaskUpdate(originalTask, updatedTask);
}
},
onTaskContextMenu: (event, task) => {
if (task) this.viewHandlers.onTaskContextMenu(event, task);
},
}
);
this.parentView.addChild(this.reviewComponent);
this.reviewComponent.load();
// Initialize HabitComponent
this.habitComponent = new Habit(this.plugin, this.contentArea);
this.parentView.addChild(this.habitComponent);
this.habitComponent.load();
// Initialize CalendarComponent
this.calendarComponent = new CalendarComponent(
this.app,
this.plugin,
this.contentArea,
[], // tasks will be set later
{
onTaskSelected: (task: Task | null) => {
if (task) this.viewHandlers.onTaskSelected(task);
},
onTaskCompleted: (task: Task) => {
if (task) this.viewHandlers.onTaskCompleted(task);
},
onEventContextMenu: (ev: MouseEvent, event: any) => {
if (event) this.viewHandlers.onTaskContextMenu(ev, event as any);
},
}
);
this.parentView.addChild(this.calendarComponent);
// Initialize KanbanComponent
this.kanbanComponent = new KanbanComponent(
this.app,
this.plugin,
this.contentArea,
[], // tasks will be set later
{
onTaskStatusUpdate: async (taskId, newStatusMark) => {
this.viewHandlers.onKanbanTaskStatusUpdate(taskId, newStatusMark);
},
onTaskSelected: (task) => {
if (task) this.viewHandlers.onTaskSelected(task);
},
onTaskCompleted: (task) => {
if (task) this.viewHandlers.onTaskCompleted(task);
},
onTaskContextMenu: (event, task) => {
if (task) this.viewHandlers.onTaskContextMenu(event, task);
},
}
);
this.parentView.addChild(this.kanbanComponent);
// Initialize GanttComponent
this.ganttComponent = new GanttComponent(
this.plugin,
this.contentArea,
{
onTaskSelected: (task: Task) =>
this.viewHandlers.onTaskSelected(task),
onTaskCompleted: (task: Task) =>
this.viewHandlers.onTaskCompleted(task),
onTaskContextMenu: (event: MouseEvent, task: Task) =>
this.viewHandlers.onTaskContextMenu(event, task),
}
);
this.parentView.addChild(this.ganttComponent);
this.ganttComponent.load();
// Create legacy containers for backward compatibility
this.listContainer = this.contentArea.createDiv({
cls: "task-list-container",
attr: { style: "display: none;" },
});
this.treeContainer = this.contentArea.createDiv({
cls: "task-tree-container",
attr: { style: "display: none;" },
});
// Hide all components initially
console.log("[FluentComponent] Hiding all components initially");
this.hideAllComponents(true);
console.log("[FluentComponent] initializeViewComponents completed");
}
/**
* Hide all components
*/
hideAllComponents(forceHideAll = false): void {
const isInitialHide = forceHideAll;
// Smart hiding - only hide currently visible component (unless initial hide)
if (!isInitialHide && this.currentVisibleComponent) {
console.log(
"[FluentComponent] Smart hide - only hiding current visible component"
);
this.currentVisibleComponent.containerEl?.hide();
this.currentVisibleComponent = null;
} else {
// Hide all components
console.log(
"[FluentComponent] Hiding all components",
isInitialHide ? "(initial hide)" : ""
);
this.contentComponent?.containerEl.hide();
this.forecastComponent?.containerEl.hide();
this.tagsComponent?.containerEl.hide();
this.projectsComponent?.containerEl.hide();
this.reviewComponent?.containerEl.hide();
this.habitComponent?.containerEl.hide();
this.calendarComponent?.containerEl.hide();
this.kanbanComponent?.containerEl.hide();
this.ganttComponent?.containerEl.hide();
this.viewComponentManager?.hideAllComponents();
// Hide two column views
this.twoColumnViewComponents.forEach((component) => {
component.containerEl.hide();
});
// Hide legacy containers
this.listContainer?.hide();
this.treeContainer?.hide();
}
}
/**
* Switch to a specific view
*/
switchView(
viewId: string,
tasks: Task[],
filteredTasks: Task[],
currentFilterState: RootFilterState | null,
viewMode: ViewMode,
project?: string | null
): void {
console.log("[FluentComponent] switchView called with:", viewId, "viewMode:", viewMode);
// Remove transient overlays (loading/error/empty) before showing components
if (this.contentArea) {
this.contentArea
.querySelectorAll(
".tg-v2-loading, .tg-v2-error-state, .tg-v2-empty-state"
)
.forEach((el) => el.remove());
}
// Hide all components first
console.log("[FluentComponent] Hiding all components");
this.hideAllComponents();
// Check if current view supports multiple view modes and we're in a non-list mode
const viewModes = this.getAvailableModesForView(viewId);
// If the current view mode is not available for this view, reset to first available or list
if (viewModes.length > 0 && !viewModes.includes(viewMode)) {
viewMode = viewModes[0];
}
console.log(
"[FluentComponent] Is content-based view?",
this.isContentBasedView(viewId),
"View mode:",
viewMode,
"Available modes:",
viewModes
);
if (
this.isContentBasedView(viewId) &&
viewModes.length > 0 &&
viewMode !== "list" &&
viewMode !== "tree"
) {
// For content-based views in kanban/calendar mode, use special rendering
console.log(
"[FluentComponent] Using renderContentWithViewMode for non-list/tree mode:",
viewMode
);
this.renderContentWithViewMode(viewId, tasks, filteredTasks, viewMode);
return;
}
// Get view configuration
const viewConfig = getViewSettingOrDefault(this.plugin, viewId as any);
let targetComponent: any = null;
let modeForComponent: string = viewId;
// Handle TwoColumn views
if (viewConfig.specificConfig?.viewType === "twocolumn") {
if (!this.twoColumnViewComponents.has(viewId)) {
const twoColumnConfig =
viewConfig.specificConfig as TwoColumnSpecificConfig;
const twoColumnComponent = new TaskPropertyTwoColumnView(
this.contentArea,
this.app,
this.plugin,
twoColumnConfig,
viewId
);
this.parentView.addChild(twoColumnComponent);
// Set up event handlers
twoColumnComponent.onTaskSelected = (task) =>
this.viewHandlers.onTaskSelected(task);
twoColumnComponent.onTaskCompleted = (task) =>
this.viewHandlers.onTaskCompleted(task);
twoColumnComponent.onTaskContextMenu = (event, task) =>
this.viewHandlers.onTaskContextMenu(event, task);
this.twoColumnViewComponents.set(viewId, twoColumnComponent);
}
targetComponent = this.twoColumnViewComponents.get(viewId);
} else {
// Check special view types
const specificViewType = viewConfig.specificConfig?.viewType;
// Check if it's a special view managed by ViewComponentManager
if (this.viewComponentManager.isSpecialView(viewId)) {
targetComponent =
this.viewComponentManager.showComponent(viewId);
} else if (
specificViewType === "forecast" ||
viewId === "forecast"
) {
targetComponent = this.forecastComponent;
} else {
// Standard view types
switch (viewId) {
case "habit":
targetComponent = this.habitComponent;
break;
case "tags":
targetComponent = this.tagsComponent;
break;
case "projects":
// In V2, Projects is treated as a content-based view using global project filters
targetComponent = this.contentComponent;
modeForComponent = viewId;
break;
case "review":
targetComponent = this.reviewComponent;
break;
case "calendar":
targetComponent = this.calendarComponent;
break;
case "kanban":
targetComponent = this.kanbanComponent;
break;
case "gantt":
targetComponent = this.ganttComponent;
break;
case "inbox":
case "today":
case "upcoming":
case "flagged":
default:
// These are handled by ContentComponent
targetComponent = this.contentComponent;
modeForComponent = viewId;
break;
}
}
}
console.log(
"[FluentComponent] Target component determined:",
targetComponent?.constructor?.name
);
if (targetComponent) {
console.log(
`[FluentComponent] Activating component for view ${viewId}:`,
targetComponent.constructor.name
);
targetComponent.containerEl.show();
this.currentVisibleComponent = targetComponent;
// Set view mode first for ContentComponent
if (typeof targetComponent.setViewMode === "function") {
console.log(
`[FluentComponent] Setting view mode for ${viewId} to ${modeForComponent}`
);
targetComponent.setViewMode(modeForComponent as any, project);
}
// Set tasks on the component
if (typeof targetComponent.setTasks === "function") {
// Special handling for components that need only all tasks (single parameter)
if (viewId === "review" || viewId === "tags") {
console.log(
`[FluentComponent] Calling setTasks for ${viewId} with ALL tasks:`,
tasks.length
);
targetComponent.setTasks(tasks);
} else {
// Use filtered tasks
let filteredTasksLocal = [...filteredTasks];
// Forecast view: remove badge-only items
if (viewId === "forecast") {
filteredTasksLocal = filteredTasksLocal.filter(
(task) => !(task as any).badge
);
}
console.log(
"[FluentComponent] Calling setTasks with filtered:",
filteredTasksLocal.length,
"all:",
tasks.length
);
targetComponent.setTasks(filteredTasksLocal, tasks);
}
}
// Handle updateTasks method for table view adapter
if (typeof targetComponent.updateTasks === "function") {
const filterOptions: any = {};
if (
currentFilterState &&
currentFilterState.filterGroups &&
currentFilterState.filterGroups.length > 0
) {
filterOptions.advancedFilter = currentFilterState;
}
targetComponent.updateTasks(
filterTasks(
tasks,
viewId as any,
this.plugin,
filterOptions
)
);
}
// Refresh review settings if needed
if (
viewId === "review" &&
typeof targetComponent.refreshReviewSettings === "function"
) {
targetComponent.refreshReviewSettings();
}
} else {
console.warn(
`[FluentComponent] No target component found for viewId: ${viewId}`
);
}
}
/**
* Render content with specific view mode (list/tree/kanban/calendar)
*/
renderContentWithViewMode(
viewId: string,
tasks: Task[],
filteredTasks: Task[],
viewMode: ViewMode
): void {
console.log(
"[FluentComponent] renderContentWithViewMode called, viewMode:",
viewMode
);
// Hide current component
this.hideAllComponents();
// Based on the current view mode, show the appropriate component
switch (viewMode) {
case "list":
case "tree":
// Use ContentComponent for list and tree views
if (!this.contentComponent) return;
this.contentComponent.containerEl.show();
this.contentComponent.setViewMode(viewId as any);
this.contentComponent.setIsTreeView(viewMode === "tree");
console.log(
"[FluentComponent] Setting tasks to ContentComponent, filtered:",
filteredTasks.length
);
this.contentComponent.setTasks(filteredTasks, tasks);
this.currentVisibleComponent = this.contentComponent;
break;
case "kanban":
// Use KanbanComponent
if (!this.kanbanComponent) return;
this.kanbanComponent.containerEl.show();
console.log(
"[FluentComponent] Setting",
filteredTasks.length,
"tasks to kanban"
);
this.kanbanComponent.setTasks(filteredTasks);
this.currentVisibleComponent = this.kanbanComponent;
break;
case "calendar":
// Use CalendarComponent
console.log(
"[FluentComponent] Calendar mode in renderContentWithViewMode"
);
if (!this.calendarComponent) {
console.log("[FluentComponent] No calendar component available!");
return;
}
console.log("[FluentComponent] Showing calendar component");
this.calendarComponent.containerEl.show();
console.log(
"[FluentComponent] Setting",
filteredTasks.length,
"tasks to calendar"
);
this.calendarComponent.setTasks(filteredTasks);
this.currentVisibleComponent = this.calendarComponent;
console.log("[FluentComponent] Calendar mode setup complete");
break;
}
}
/**
* Refresh current view data without full re-render
*/
refreshCurrentViewData(
viewId: string,
tasks: Task[],
filteredTasks: Task[],
viewMode: ViewMode
): void {
// Content-based views (list/tree/kanban/calendar)
if (this.isContentBasedView(viewId)) {
switch (viewMode) {
case "kanban":
this.kanbanComponent?.setTasks?.(filteredTasks);
break;
case "calendar":
this.calendarComponent?.setTasks?.(filteredTasks);
break;
case "tree":
case "list":
default:
this.contentComponent?.setTasks?.(
filteredTasks,
tasks,
true
);
break;
}
return;
}
// Special/other views
if (this.viewComponentManager?.isSpecialView(viewId)) {
const comp: any = (
this.viewComponentManager as any
).getOrCreateComponent(viewId);
if (comp?.updateTasks) {
comp.updateTasks(filteredTasks);
} else if (comp?.setTasks) {
comp.setTasks(filteredTasks, tasks);
}
return;
}
// Direct known components fallback
const mapping: Record<string, any> = {
forecast: this.forecastComponent,
tags: this.tagsComponent,
projects: this.contentComponent,
review: this.reviewComponent,
habit: this.habitComponent,
gantt: this.ganttComponent,
kanban: this.kanbanComponent,
calendar: this.calendarComponent,
};
const target: any = (mapping as any)[viewId];
if (target?.setTasks) {
if (viewId === "projects" || this.isContentBasedView(viewId)) {
target.setTasks(filteredTasks, tasks, true);
} else if (viewId === "review" || viewId === "tags") {
target.setTasks(tasks);
} else {
target.setTasks(filteredTasks);
}
} else if (target?.updateTasks) {
target.updateTasks(filteredTasks);
}
}
/**
* Render loading state
*/
renderLoadingState(): void {
console.log("[FluentComponent] renderLoadingState called");
if (this.contentArea) {
// Remove existing loading overlays
this.contentArea
.querySelectorAll(".tg-v2-loading")
.forEach((el) => el.remove());
const loadingEl = this.contentArea.createDiv({
cls: "tg-v2-loading",
});
loadingEl.createDiv({ cls: "tg-v2-spinner" });
loadingEl.createDiv({
cls: "tg-v2-loading-text",
text: t("Loading tasks..."),
});
}
}
/**
* Render error state
*/
renderErrorState(errorMessage: string, onRetry: () => void): void {
if (this.contentArea) {
this.contentArea
.querySelectorAll(".tg-v2-error-state")
.forEach((el) => el.remove());
const errorEl = this.contentArea.createDiv({
cls: "tg-v2-error-state",
});
const errorIcon = errorEl.createDiv({ cls: "tg-v2-error-icon" });
setIcon(errorIcon, "alert-triangle");
errorEl.createDiv({
cls: "tg-v2-error-title",
text: t("Failed to load tasks"),
});
errorEl.createDiv({
cls: "tg-v2-error-message",
text: errorMessage || t("An unexpected error occurred"),
});
const retryBtn = errorEl.createEl("button", {
cls: "tg-v2-button tg-v2-button-primary",
text: t("Retry"),
});
retryBtn.addEventListener("click", onRetry);
}
}
/**
* Render empty state
*/
renderEmptyState(): void {
if (this.contentArea) {
this.contentArea
.querySelectorAll(".tg-v2-empty-state")
.forEach((el) => el.remove());
const emptyEl = this.contentArea.createDiv({
cls: "tg-v2-empty-state",
});
const emptyIcon = emptyEl.createDiv({ cls: "tg-v2-empty-icon" });
setIcon(emptyIcon, "inbox");
emptyEl.createDiv({
cls: "tg-v2-empty-title",
text: t("No tasks yet"),
});
emptyEl.createDiv({
cls: "tg-v2-empty-description",
text: t(
"Create your first task to get started with Task Genius"
),
});
const createBtn = emptyEl.createEl("button", {
cls: "tg-v2-button tg-v2-button-primary",
text: t("Create Task"),
});
createBtn.addEventListener("click", () => {
new QuickCaptureModal(this.app, this.plugin).open();
});
}
}
/**
* Check if view is content-based (supports multiple view modes)
*/
private isContentBasedView(viewId: string): boolean {
const contentBasedViews = [
"inbox",
"today",
"upcoming",
"flagged",
"projects",
];
return contentBasedViews.includes(viewId);
}
/**
* Get available view modes for a specific view
*/
getAvailableModesForView(viewId: string): ViewMode[] {
// Check for special two-column views
const viewConfig = getViewSettingOrDefault(this.plugin, viewId as any);
if (viewConfig?.specificConfig?.viewType === "twocolumn") {
return [];
}
// Check for special views managed by ViewComponentManager
if (this.viewComponentManager?.isSpecialView(viewId)) {
return [];
}
// Return the configured modes for the view, or empty array
return VIEW_MODE_CONFIG[viewId] || [];
}
/**
* Clean up on unload
*/
onunload(): void {
// Components will be cleaned up by parent Component lifecycle
super.onunload();
}
}

View file

@ -0,0 +1,327 @@
import { Component, debounce } from "obsidian";
import TaskProgressBarPlugin from "@/index";
import { Task } from "@/types/task";
import { filterTasks } from "@/utils/task/task-filter-utils";
import { RootFilterState } from "@/components/features/task/filter/ViewTaskFilter";
import { isDataflowEnabled } from "@/dataflow/createDataflow";
import { Events, on } from "@/dataflow/events/Events";
/**
* FluentDataManager - Stateless data loading and filtering executor
*
* Responsibilities:
* - Load tasks from dataflow or preloaded cache (returns via callback)
* - Apply filters to tasks (pure function, returns filtered tasks)
* - Register dataflow event listeners for real-time updates
* - Schedule and batch updates to prevent rapid re-renders
*
* NOTE: This manager is STATELESS - it does not hold tasks or filtered tasks.
* All state is managed by TaskViewV2, this manager only executes operations.
*/
export class FluentDataManager extends Component {
// Callbacks
private onTasksLoaded?: (tasks: Task[], error: string | null) => void;
private onLoadingStateChanged?: (isLoading: boolean) => void;
private onUpdateNeeded?: (source: string) => void;
constructor(
private plugin: TaskProgressBarPlugin,
private getCurrentViewId: () => string,
private getCurrentFilterState: () => {
liveFilterState: RootFilterState | null;
currentFilterState: RootFilterState | null;
viewStateFilters: any;
selectedProject: string | undefined;
searchQuery: string;
filterInputValue: string;
},
private isInitializing: () => boolean
) {
super();
}
/**
* Set callbacks for data operations
*/
setCallbacks(callbacks: {
onTasksLoaded?: (tasks: Task[], error: string | null) => void;
onLoadingStateChanged?: (isLoading: boolean) => void;
onUpdateNeeded?: (source: string) => void;
}) {
this.onTasksLoaded = callbacks.onTasksLoaded;
this.onLoadingStateChanged = callbacks.onLoadingStateChanged;
this.onUpdateNeeded = callbacks.onUpdateNeeded;
}
/**
* Load tasks from dataflow or preloaded cache
* Returns loaded tasks via callback
*/
async loadTasks(showLoading = true): Promise<void> {
try {
console.log("[FluentData] loadTasks started, showLoading:", showLoading);
// Notify loading state
if (showLoading && !this.isInitializing()) {
console.log("[FluentData] Notifying loading state");
this.onLoadingStateChanged?.(true);
}
let loadedTasks: Task[] = [];
if (this.plugin.dataflowOrchestrator) {
console.log("[FluentData] Using dataflow orchestrator to load tasks");
const queryAPI = this.plugin.dataflowOrchestrator.getQueryAPI();
console.log("[FluentData] Getting all tasks from queryAPI...");
loadedTasks = await queryAPI.getAllTasks();
console.log(`[FluentData] Loaded ${loadedTasks.length} tasks from dataflow`);
} else {
console.log("[FluentData] Dataflow not available, using preloaded tasks");
loadedTasks = this.plugin.preloadedTasks || [];
console.log(`[FluentData] Loaded ${loadedTasks.length} preloaded tasks`);
}
// Return loaded tasks via callback
this.onTasksLoaded?.(loadedTasks, null);
} catch (error) {
console.error("[FluentData] Failed to load tasks:", error);
const errorMessage = (error as Error).message || "Failed to load tasks";
// Return error via callback
this.onTasksLoaded?.([], errorMessage);
} finally {
// Always notify loading complete
console.log("[FluentData] loadTasks complete");
this.onLoadingStateChanged?.(false);
}
}
/**
* Apply filters to tasks (pure function - returns filtered tasks)
* @param tasks - All tasks to filter
* @returns Filtered tasks based on current filter state
*/
applyFilters(tasks: Task[]): Task[] {
const viewId = this.getCurrentViewId();
const filterState = this.getCurrentFilterState();
// Build filter options
const filterOptions: any = {
textQuery: filterState.filterInputValue || filterState.searchQuery || "",
};
// Apply advanced filters from the filter popover/modal
if (
filterState.currentFilterState &&
filterState.currentFilterState.filterGroups &&
filterState.currentFilterState.filterGroups.length > 0
) {
console.log("[FluentData] Applying advanced filters");
filterOptions.advancedFilter = filterState.currentFilterState;
}
// If there are additional V2-specific filters from the filter panel, pass them
if (
filterState.viewStateFilters &&
Object.keys(filterState.viewStateFilters).length > 0
) {
filterOptions.v2Filters = filterState.viewStateFilters;
}
// Global project filter - Skip for Inbox view (Inbox tasks don't have projects by definition)
if (filterState.selectedProject && viewId !== "inbox") {
filterOptions.v2Filters = {
...(filterOptions.v2Filters || {}),
project: filterState.selectedProject,
};
}
// Use the existing filterTasks utility which handles all view-specific logic
let filteredTasks = filterTasks(
tasks,
viewId as any,
this.plugin,
filterOptions
);
// Apply additional V2-specific filters if needed
if (filterOptions.v2Filters) {
filteredTasks = this.applyV2Filters(filteredTasks, filterOptions.v2Filters);
}
console.log(`[FluentData] Filtered ${filteredTasks.length} tasks from ${tasks.length} total`);
return filteredTasks;
}
/**
* Apply V2-specific filters (pure function - returns filtered tasks)
* @param tasks - Tasks to filter
* @param filters - V2 filter configuration
* @returns Filtered tasks
*/
private applyV2Filters(tasks: Task[], filters: any): Task[] {
const viewId = this.getCurrentViewId();
let result = [...tasks]; // Copy array to avoid mutation
// Status filter
if (filters.status && filters.status !== "all") {
switch (filters.status) {
case "active":
result = result.filter((task) => !task.completed);
break;
case "completed":
result = result.filter((task) => task.completed);
break;
case "overdue":
result = result.filter((task) => {
if (task.completed || !task.metadata?.dueDate) return false;
return new Date(task.metadata.dueDate) < new Date();
});
break;
}
}
// Priority filter
if (filters.priority && filters.priority !== "all") {
result = result.filter((task) => {
const taskPriority = task.metadata?.priority || 0;
const filterPriority =
typeof filters.priority === "string"
? parseInt(filters.priority)
: filters.priority;
return taskPriority === filterPriority;
});
}
// Project filter - Skip for Inbox view
if (filters.project && viewId !== "inbox") {
result = result.filter(
(task) => task.metadata?.project === filters.project
);
}
// Tags filter
if (filters.tags && filters.tags.length > 0) {
result = result.filter((task) => {
if (!task.metadata?.tags) return false;
return filters.tags!.some((tag: string) =>
task.metadata!.tags!.includes(tag)
);
});
}
// Date range filter
if (filters.dateRange) {
if (filters.dateRange.start) {
result = result.filter((task) => {
if (!task.metadata?.dueDate) return false;
return (
new Date(task.metadata.dueDate) >= filters.dateRange!.start!
);
});
}
if (filters.dateRange.end) {
result = result.filter((task) => {
if (!task.metadata?.dueDate) return false;
return (
new Date(task.metadata.dueDate) <= filters.dateRange!.end!
);
});
}
}
// Assignee filter
if (filters.assignee) {
result = result.filter(
(task) => task.metadata?.assignee === filters.assignee
);
}
return result;
}
/**
* Register dataflow event listeners for real-time updates
* Notifies parent via onUpdateNeeded callback
*/
async registerDataflowListeners(): Promise<void> {
// Add debounced view update to prevent rapid successive refreshes
const debouncedViewUpdate = debounce(async () => {
console.log("[FluentData] debouncedViewUpdate triggered");
if (!this.isInitializing()) {
// Load tasks and notify parent
await this.loadTasks(false);
// Notify parent that data changed
this.onUpdateNeeded?.("dataflow");
}
}, 500);
// Add debounced filter application
const debouncedApplyFilter = debounce(() => {
if (!this.isInitializing()) {
this.onUpdateNeeded?.("filter-changed");
}
}, 400);
// Register dataflow event listeners
if (
isDataflowEnabled(this.plugin) &&
this.plugin.dataflowOrchestrator
) {
// Listen for cache ready event
this.registerEvent(
on(this.plugin.app, Events.CACHE_READY, async () => {
await this.loadTasks();
this.onUpdateNeeded?.("cache-ready");
})
);
// Listen for task cache updates
this.registerEvent(
on(this.plugin.app, Events.TASK_CACHE_UPDATED, debouncedViewUpdate)
);
} else {
// Legacy event support
this.registerEvent(
this.plugin.app.workspace.on(
"task-genius:task-cache-updated",
debouncedViewUpdate
)
);
}
// Listen for filter change events
this.registerEvent(
this.plugin.app.workspace.on(
"task-genius:filter-changed",
(filterState: RootFilterState, leafId?: string) => {
// Only update if it's from a live filter component
if (
!leafId ||
(!leafId.startsWith("view-config-") && leafId !== "global-filter")
) {
console.log("[FluentData] Filter changed, notifying update needed");
debouncedApplyFilter();
}
}
)
);
}
/**
* Get current filter count for badge display
*/
getActiveFilterCount(): number {
const filterState = this.getCurrentFilterState();
let count = 0;
if (filterState.searchQuery || filterState.filterInputValue) count++;
if (filterState.selectedProject) count++;
if (filterState.currentFilterState?.filterGroups?.length) {
count += filterState.currentFilterState.filterGroups.length;
}
return count;
}
}

View file

@ -0,0 +1,130 @@
import { Component, Platform } from "obsidian";
/**
* FluentGestureManager - Manages mobile touch gestures
*
* Responsibilities:
* - Edge swipe to open drawer (from left edge)
* - Swipe left to close drawer (when open)
* - Touch event handling with vertical scroll detection
*/
export class FluentGestureManager extends Component {
// Touch gesture tracking
private touchStartX = 0;
private touchStartY = 0;
private touchCurrentX = 0;
private isSwiping = false;
private swipeThreshold = 50;
// Callbacks
private onOpenDrawer?: () => void;
private onCloseDrawer?: () => void;
private getIsMobileDrawerOpen?: () => boolean;
constructor(
private rootContainerEl: HTMLElement
) {
super();
}
/**
* Set drawer callbacks
*/
setDrawerCallbacks(callbacks: {
onOpenDrawer: () => void;
onCloseDrawer: () => void;
getIsMobileDrawerOpen: () => boolean;
}): void {
this.onOpenDrawer = callbacks.onOpenDrawer;
this.onCloseDrawer = callbacks.onCloseDrawer;
this.getIsMobileDrawerOpen = callbacks.getIsMobileDrawerOpen;
}
/**
* Initialize mobile swipe gestures for drawer
*/
initializeMobileSwipeGestures(): void {
if (!Platform.isPhone) return;
// Edge swipe to open drawer
this.registerDomEvent(document, "touchstart", (e: TouchEvent) => {
const isMobileDrawerOpen = this.getIsMobileDrawerOpen?.() ?? false;
if (isMobileDrawerOpen) {
// Track for swipe-to-close when drawer is open
const touch = e.touches[0];
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;
this.isSwiping = true;
} else {
// Check if touch started from left edge
const touch = e.touches[0];
if (touch.clientX < 20) {
// 20px edge detection zone
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;
this.isSwiping = true;
}
}
});
this.registerDomEvent(document, "touchmove", (e: TouchEvent) => {
if (!this.isSwiping) return;
const touch = e.touches[0];
this.touchCurrentX = touch.clientX;
const deltaX = this.touchCurrentX - this.touchStartX;
const deltaY = Math.abs(touch.clientY - this.touchStartY);
// Check if horizontal swipe (not vertical scroll)
if (deltaY > 50) {
this.isSwiping = false;
return;
}
const isMobileDrawerOpen = this.getIsMobileDrawerOpen?.() ?? false;
if (!isMobileDrawerOpen && deltaX > this.swipeThreshold) {
// Swipe right from edge - open drawer
this.onOpenDrawer?.();
this.isSwiping = false;
} else if (
isMobileDrawerOpen &&
deltaX < -this.swipeThreshold
) {
// Swipe left when drawer is open - close it
const sidebarEl = this.rootContainerEl?.querySelector(
".tg-v2-sidebar-container"
);
if (sidebarEl) {
const sidebarRect = sidebarEl.getBoundingClientRect();
// Only close if swipe started on the sidebar
if (this.touchStartX < sidebarRect.right) {
this.onCloseDrawer?.();
this.isSwiping = false;
}
}
}
});
this.registerDomEvent(document, "touchend", () => {
this.isSwiping = false;
this.touchStartX = 0;
this.touchCurrentX = 0;
});
this.registerDomEvent(document, "touchcancel", () => {
this.isSwiping = false;
this.touchStartX = 0;
this.touchCurrentX = 0;
});
}
/**
* Clean up on unload
*/
onunload(): void {
// Event listeners will be cleaned up by Component lifecycle
super.onunload();
}
}

View file

@ -0,0 +1,609 @@
import { Component, Platform, ButtonComponent, WorkspaceLeaf, ItemView } from "obsidian";
import { App } from "obsidian";
import TaskProgressBarPlugin from "@/index";
import { V2Sidebar } from "../components/V2Sidebar";
import { TaskDetailsComponent } from "@/components/features/task/view/details";
import { Task } from "@/types/task";
import { t } from "@/translations/helper";
import { TG_LEFT_SIDEBAR_VIEW_TYPE } from "../views/LeftSidebarView";
import { QuickCaptureModal } from "@/components/features/quick-capture/modals/QuickCaptureModal";
import {
ViewTaskFilterPopover,
ViewTaskFilterModal,
} from "@/components/features/task/filter";
import { RootFilterState } from "@/components/features/task/filter/ViewTaskFilter";
/**
* FluentLayoutManager - Manages layout, sidebar, and details panel
*
* Responsibilities:
* - Sidebar toggle (supports both leaves and non-leaves mode)
* - Details panel visibility (supports both leaves and non-leaves mode)
* - Mobile drawer management
* - Responsive layout adjustments
* - Header buttons (sidebar toggle, task count)
*
* KEY ARCHITECTURAL CONSIDERATION:
* This manager handles TWO distinct modes:
* 1. Leaves Mode (useWorkspaceSideLeaves: true): Sidebar/Details in separate workspace leaves
* 2. Non-Leaves Mode: Sidebar/Details embedded in main view
*/
export class FluentLayoutManager extends Component {
// Layout state
public isSidebarCollapsed = false;
public isDetailsVisible = false;
public isMobileDrawerOpen = false;
// UI elements
private sidebarToggleBtn: HTMLElement | null = null;
private drawerOverlay: HTMLElement | null = null;
private detailsToggleBtn: HTMLElement | null = null;
private mobileDetailsOverlayHandler?: (e: MouseEvent) => void;
// Components (non-leaves mode only)
public sidebar: V2Sidebar | null = null;
public detailsComponent: TaskDetailsComponent | null = null;
// Callbacks
private onSidebarNavigate?: (viewId: string) => void;
private onProjectSelect?: (projectId: string) => void;
private onTaskToggleComplete?: (task: Task) => void;
private onTaskEdit?: (task: Task) => void;
private onTaskUpdate?: (originalTask: Task, updatedTask: Task) => Promise<void>;
private onFilterReset?: () => void;
private getLiveFilterState?: () => RootFilterState | null;
private leaf: WorkspaceLeaf;
constructor(
private app: App,
private plugin: TaskProgressBarPlugin,
private view: ItemView,
private rootContainerEl: HTMLElement,
private headerEl: HTMLElement,
private titleEl: HTMLElement,
private getTaskCount: () => number
) {
super();
this.leaf = view.leaf;
}
/**
* Set navigation callback
*/
setOnSidebarNavigate(callback: (viewId: string) => void): void {
this.onSidebarNavigate = callback;
}
/**
* Set project select callback
*/
setOnProjectSelect(callback: (projectId: string) => void): void {
this.onProjectSelect = callback;
}
/**
* Set task callbacks for details component
*/
setTaskCallbacks(callbacks: {
onTaskToggleComplete: (task: Task) => void;
onTaskEdit: (task: Task) => void;
onTaskUpdate: (originalTask: Task, updatedTask: Task) => Promise<void>;
}): void {
this.onTaskToggleComplete = callbacks.onTaskToggleComplete;
this.onTaskEdit = callbacks.onTaskEdit;
this.onTaskUpdate = callbacks.onTaskUpdate;
}
/**
* Set filter callbacks
*/
setFilterCallbacks(callbacks: {
onFilterReset: () => void;
getLiveFilterState: () => RootFilterState | null;
}): void {
this.onFilterReset = callbacks.onFilterReset;
this.getLiveFilterState = callbacks.getLiveFilterState;
}
/**
* Check if using workspace side leaves mode
*/
private useSideLeaves(): boolean {
return !!(
(this.plugin.settings.experimental as any)?.v2Config
?.useWorkspaceSideLeaves
);
}
/**
* Initialize sidebar (non-leaves mode only)
*/
initializeSidebar(containerEl: HTMLElement): void {
if (this.useSideLeaves()) {
containerEl.hide();
console.log(
"[FluentLayout] Using workspace side leaves: skip in-view sidebar"
);
return;
}
// On mobile, start with sidebar completely hidden (drawer closed)
const initialCollapsedState = Platform.isPhone
? true
: this.isSidebarCollapsed;
this.sidebar = new V2Sidebar(
containerEl,
this.plugin,
(viewId) => {
this.onSidebarNavigate?.(viewId);
// Auto-close drawer on mobile after navigation
if (Platform.isPhone) {
this.closeMobileDrawer();
}
},
(projectId) => {
this.onProjectSelect?.(projectId);
// Auto-close drawer on mobile after selection
if (Platform.isPhone) {
this.closeMobileDrawer();
}
},
initialCollapsedState
);
// Add sidebar as a child component for proper lifecycle management
this.addChild(this.sidebar);
}
/**
* Initialize details component (non-leaves mode only)
*/
initializeDetailsComponent(): void {
if (this.useSideLeaves()) {
console.log(
"[FluentLayout] Using workspace side leaves: skip in-view details panel"
);
return;
}
// Initialize details component (hidden by default)
this.detailsComponent = new TaskDetailsComponent(
this.rootContainerEl,
this.app,
this.plugin
);
this.addChild(this.detailsComponent);
this.detailsComponent.load();
// Set up callbacks
this.detailsComponent.onTaskToggleComplete = (task: Task) =>
this.onTaskToggleComplete?.(task);
this.detailsComponent.onTaskEdit = (task: Task) =>
this.onTaskEdit?.(task);
this.detailsComponent.onTaskUpdate = async (
originalTask: Task,
updatedTask: Task
) => {
await this.onTaskUpdate?.(originalTask, updatedTask);
};
this.detailsComponent.toggleDetailsVisibility = (visible: boolean) => {
this.toggleDetailsVisibility(visible);
};
this.detailsComponent.setVisible(this.isDetailsVisible);
}
/**
* Create sidebar toggle button in header
*/
createSidebarToggle(): void {
const headerBtns = !Platform.isPhone
? (this.headerEl?.querySelector(
".view-header-nav-buttons"
) as HTMLElement | null)
: (this.headerEl?.querySelector(".view-header-left") as HTMLElement);
if (!headerBtns) {
console.warn("[FluentLayout] header buttons container not found");
return;
}
const container = headerBtns.createDiv({
cls: "panel-toggle-container",
});
this.sidebarToggleBtn = container.createDiv({
cls: "panel-toggle-btn",
});
const btn = new ButtonComponent(this.sidebarToggleBtn);
btn.setIcon(Platform.isPhone ? "menu" : "panel-left-dashed")
.setTooltip(t("Toggle Sidebar"))
.setClass("clickable-icon")
.onClick(() => this.toggleSidebar());
}
/**
* Create task count mark in title
*/
createTaskMark(): void {
this.updateTaskMark();
}
/**
* Update task count in title
*/
updateTaskMark(): void {
this.titleEl.setText(
t("{{num}} Tasks", {
interpolation: {
num: this.getTaskCount(),
},
})
);
}
/**
* Toggle sidebar visibility
* Behavior differs based on mode:
* - Leaves mode: Toggle workspace left split
* - Non-leaves mode: Toggle internal sidebar component
* - Mobile: Toggle drawer overlay
*/
toggleSidebar(): void {
if (this.useSideLeaves()) {
// In side-leaf mode, toggle the left sidebar split collapse state
const ws = this.app.workspace;
const leftSplit = ws.leftSplit;
const isCollapsed = !!leftSplit?.collapsed;
if (isCollapsed) {
// Expand and ensure our sidebar view
leftSplit.expand();
// Handle async ensureSideLeaf
ws.ensureSideLeaf(TG_LEFT_SIDEBAR_VIEW_TYPE, "left", {active: false}).then(leftLeaf => {
if (leftLeaf) {
ws.revealLeaf?.(leftLeaf);
}
});
} else {
leftSplit.collapse();
}
return;
}
if (Platform.isPhone) {
// On mobile, toggle the drawer open/closed
if (this.isMobileDrawerOpen) {
this.closeMobileDrawer();
} else {
this.openMobileDrawer();
}
} else {
// On desktop, toggle collapse state
this.isSidebarCollapsed = !this.isSidebarCollapsed;
this.sidebar?.setCollapsed(this.isSidebarCollapsed);
this.rootContainerEl?.toggleClass(
"v2-sidebar-collapsed",
this.isSidebarCollapsed
);
}
}
/**
* Open mobile drawer
*/
openMobileDrawer(): void {
this.isMobileDrawerOpen = true;
this.rootContainerEl?.addClass("drawer-open");
if (this.drawerOverlay) {
this.drawerOverlay.style.display = "block";
}
// Show the sidebar
this.sidebar?.setCollapsed(false);
}
/**
* Close mobile drawer
*/
closeMobileDrawer(): void {
this.isMobileDrawerOpen = false;
this.rootContainerEl?.removeClass("drawer-open");
if (this.drawerOverlay) {
this.drawerOverlay.style.display = "none";
}
// Hide the sidebar
this.sidebar?.setCollapsed(true);
}
/**
* Set up drawer overlay for mobile
*/
setupDrawerOverlay(layoutContainer: HTMLElement): void {
if (!Platform.isPhone) return;
this.drawerOverlay = layoutContainer.createDiv({
cls: "drawer-overlay",
});
this.drawerOverlay.style.display = "none";
this.drawerOverlay.addEventListener("click", () => {
this.closeMobileDrawer();
});
}
/**
* Toggle details panel visibility
* Behavior differs based on mode:
* - Leaves mode: Toggle workspace right split
* - Non-leaves mode: Toggle internal details component
* - Mobile: Overlay with backdrop
*/
toggleDetailsVisibility(visible: boolean): void {
this.isDetailsVisible = visible;
if (this.useSideLeaves()) {
// In side-leaf mode, reveal/collapse the right details pane
const ws = this.app.workspace;
if (visible) {
// Try to expand right split if it's collapsed
if (
ws.rightSplit?.collapsed
) {
ws.rightSplit.expand();
}
} else {
ws.rightSplit.collapse();
}
// Update header toggle visual state
if (this.detailsToggleBtn) {
this.detailsToggleBtn.toggleClass("is-active", visible);
this.detailsToggleBtn.setAttribute(
"aria-label",
visible ? t("Hide Details") : t("Show Details")
);
}
return;
}
// Legacy/in-view mode
this.rootContainerEl?.toggleClass("details-visible", visible);
this.rootContainerEl?.toggleClass("details-hidden", !visible);
if (this.detailsComponent) {
this.detailsComponent.setVisible(visible);
}
if (this.detailsToggleBtn) {
this.detailsToggleBtn.toggleClass("is-active", visible);
this.detailsToggleBtn.setAttribute(
"aria-label",
visible ? t("Hide Details") : t("Show Details")
);
}
// On mobile, add click handler to overlay to close details
if (Platform.isPhone && visible) {
// Use setTimeout to avoid immediate close on open
setTimeout(() => {
const overlayClickHandler = (e: MouseEvent) => {
// Check if click is on the overlay (pseudo-element area)
const detailsEl = this.rootContainerEl?.querySelector(
".task-details"
);
if (detailsEl && !detailsEl.contains(e.target as Node)) {
this.toggleDetailsVisibility(false);
document.removeEventListener("click", overlayClickHandler);
}
};
document.addEventListener("click", overlayClickHandler);
this.mobileDetailsOverlayHandler = overlayClickHandler;
}, 100);
} else if (
Platform.isPhone &&
!visible &&
this.mobileDetailsOverlayHandler
) {
document.removeEventListener(
"click",
this.mobileDetailsOverlayHandler
);
delete this.mobileDetailsOverlayHandler;
}
}
/**
* Store details toggle button reference
*/
setDetailsToggleBtn(btn: HTMLElement): void {
this.detailsToggleBtn = btn;
this.detailsToggleBtn.toggleClass("is-active", this.isDetailsVisible);
}
/**
* Check and auto-collapse sidebar on narrow screens (desktop only)
*/
checkAndCollapseSidebar(): void {
// Skip auto-collapse on mobile, as we use drawer mode
if (Platform.isPhone) {
return;
}
// Auto-collapse on narrow panes (desktop only)
try {
const width = this.leaf?.width ?? 0;
if (width > 0 && width < 768) {
this.isSidebarCollapsed = true;
this.sidebar?.setCollapsed(true);
this.rootContainerEl?.addClass("v2-sidebar-collapsed");
}
} catch (_) {
// Ignore errors
}
}
/**
* Handle window resize
*/
onResize(): void {
// Only check and collapse on desktop
if (!Platform.isPhone) {
this.checkAndCollapseSidebar();
}
}
/**
* Show task details in details panel
*/
showTaskDetails(task: Task): void {
if (this.useSideLeaves()) {
// In leaves mode, emit event to side leaf details view
// This will be handled by the main view's event emission
return;
}
this.detailsComponent?.showTaskDetails(task);
}
/**
* Set sidebar active item
*/
setSidebarActiveItem(viewId: string): void {
this.sidebar?.setActiveItem(viewId);
}
/**
* Refresh sidebar project list
*/
refreshSidebarProjects(): void {
this.sidebar?.projectList?.refresh();
}
/**
* Set active project in sidebar
*/
setActiveProject(projectId: string | null): void {
this.sidebar?.projectList?.setActiveProject(projectId);
}
/**
* Create action buttons in view header
* - Details toggle button
* - Quick capture button
* - Filter button
* - Reset filter button (conditional)
*/
createActionButtons(): void {
// Details toggle button
this.detailsToggleBtn = this.view.addAction(
"panel-right-dashed",
t("Details"),
() => {
this.toggleDetailsVisibility(!this.isDetailsVisible);
}
);
if (this.detailsToggleBtn) {
this.detailsToggleBtn.toggleClass("panel-toggle-btn", true);
this.detailsToggleBtn.toggleClass("is-active", this.isDetailsVisible);
}
// Capture button
this.view.addAction("notebook-pen", t("Capture"), () => {
const modal = new QuickCaptureModal(
this.app,
this.plugin,
{},
true
);
modal.open();
});
// Filter button
this.view.addAction("filter", t("Filter"), (e: MouseEvent) => {
if (Platform.isDesktop) {
const popover = new ViewTaskFilterPopover(
this.app,
undefined,
this.plugin
);
// Set up filter state when opening
this.app.workspace.onLayoutReady(() => {
setTimeout(() => {
const liveFilterState = this.getLiveFilterState?.();
if (
liveFilterState &&
popover.taskFilterComponent
) {
popover.taskFilterComponent.loadFilterState(
liveFilterState
);
}
}, 100);
});
popover.showAtPosition({x: e.clientX, y: e.clientY});
} else {
const modal = new ViewTaskFilterModal(
this.app,
this.leaf.id,
this.plugin
);
modal.open();
// Set initial filter state
const liveFilterState = this.getLiveFilterState?.();
if (liveFilterState && modal.taskFilterComponent) {
setTimeout(() => {
modal.taskFilterComponent.loadFilterState(liveFilterState);
}, 100);
}
}
});
// Update action buttons visibility
this.updateActionButtons();
}
/**
* Update action buttons visibility (mainly Reset Filter button)
*/
updateActionButtons(): void {
// Remove reset filter button if exists
const resetButton = this.headerEl.querySelector(
".view-action.task-filter-reset"
);
if (resetButton) {
resetButton.remove();
}
// Add reset filter button if there are active filters
const liveFilterState = this.getLiveFilterState?.();
if (
liveFilterState &&
liveFilterState.filterGroups &&
liveFilterState.filterGroups.length > 0
) {
this.view.addAction("reset", t("Reset Filter"), () => {
this.onFilterReset?.();
}).addClass("task-filter-reset");
}
}
/**
* Clean up mobile event listeners
*/
onunload(): void {
if (Platform.isPhone && (this as any).mobileDetailsOverlayHandler) {
document.removeEventListener(
"click",
(this as any).mobileDetailsOverlayHandler
);
delete (this as any).mobileDetailsOverlayHandler;
}
super.onunload();
}
}

View file

@ -0,0 +1,228 @@
import { Component, debounce, App } from "obsidian";
import TaskProgressBarPlugin from "@/index";
import { RootFilterState } from "@/components/features/task/filter/ViewTaskFilter";
import { ViewMode } from "../components/V2TopNavigation";
/**
* FluentWorkspaceStateManager - Manages workspace state persistence
*
* Responsibilities:
* - Save/restore workspace layout (filter state, view preferences)
* - Workspace switching
* - Filter state persistence to workspace overrides
* - LocalStorage management for current workspace
*/
export class FluentWorkspaceStateManager extends Component {
// Flag to prevent infinite loops during save
private isSavingFilterState = false;
constructor(
private app: App,
private plugin: TaskProgressBarPlugin,
private getWorkspaceId: () => string,
private getCurrentViewId: () => string,
private getViewState: () => {
filters: any;
selectedProject: string | undefined;
searchQuery: string;
viewMode: ViewMode;
},
private getCurrentFilterState: () => RootFilterState | null,
private getLiveFilterState: () => RootFilterState | null
) {
super();
}
/**
* Save workspace layout (filter state and preferences)
*/
saveWorkspaceLayout(): void {
const workspaceId = this.getWorkspaceId();
if (!workspaceId) return;
// Save filter state
this.saveFilterStateToWorkspace();
// Save current workspace ID to localStorage for persistence
localStorage.setItem(
"task-genius-v2-current-workspace",
workspaceId
);
}
/**
* Load workspace layout (filter state and preferences)
*/
loadWorkspaceLayout(): string | null {
// Load current workspace from localStorage
const savedCurrentWorkspace = localStorage.getItem(
"task-genius-v2-current-workspace"
);
if (savedCurrentWorkspace) {
return savedCurrentWorkspace;
}
return null;
}
/**
* Apply workspace settings
*/
async applyWorkspaceSettings(): Promise<void> {
const workspaceId = this.getWorkspaceId();
if (!this.plugin.workspaceManager || !workspaceId) return;
const settings = this.plugin.workspaceManager.getEffectiveSettings(
workspaceId
);
// Workspace settings are now restored via restoreFilterStateFromWorkspace
// This method is kept for future workspace-specific settings that are not filter-related
}
/**
* Switch to a different workspace
*/
async switchWorkspace(workspaceId: string): Promise<void> {
// Save current workspace before switching
this.saveWorkspaceLayout();
// Update workspace ID will be handled by caller
// This method just handles the save/restore logic
}
/**
* Save filter state to workspace (debounced to avoid infinite loops)
*/
saveFilterStateToWorkspace = debounce(
() => {
const workspaceId = this.getWorkspaceId();
const viewId = this.getCurrentViewId();
if (!this.plugin.workspaceManager || !workspaceId) return;
const effectiveSettings =
this.plugin.workspaceManager.getEffectiveSettings(
workspaceId
);
// Save current filter state
if (!effectiveSettings.v2FilterState) {
effectiveSettings.v2FilterState = {};
}
const viewState = this.getViewState();
const currentFilterState = this.getCurrentFilterState();
// Build payload (do NOT persist ephemeral fields across workspaces)
const payload = {
filters: viewState.filters,
selectedProject: viewState.selectedProject,
advancedFilter: currentFilterState,
viewMode: viewState.viewMode,
};
effectiveSettings.v2FilterState[viewId] = payload;
console.log("[FluentWorkspace] saveFilterStateToWorkspace", {
workspaceId: workspaceId,
viewId: viewId,
searchQuery: viewState.searchQuery,
selectedProject: viewState.selectedProject,
hasAdvanced: !!currentFilterState,
groups: (currentFilterState as any)?.filterGroups?.length ?? 0,
});
// Use saveOverridesQuietly to avoid triggering SETTINGS_CHANGED event
this.plugin.workspaceManager
.saveOverridesQuietly(workspaceId, effectiveSettings)
.then(() =>
console.log("[FluentWorkspace] overrides saved quietly", {
workspaceId: workspaceId,
viewId: viewId,
})
)
.catch((e) =>
console.warn("[FluentWorkspace] failed to save overrides", e)
);
},
500,
true
);
/**
* Restore filter state from workspace
*/
restoreFilterStateFromWorkspace(): {
filters: any;
selectedProject: string | undefined;
advancedFilter: RootFilterState | null;
viewMode: ViewMode;
shouldClearSearch: boolean;
} | null {
const workspaceId = this.getWorkspaceId();
const viewId = this.getCurrentViewId();
if (!this.plugin.workspaceManager || !workspaceId) return null;
const effectiveSettings =
this.plugin.workspaceManager.getEffectiveSettings(workspaceId);
const saved =
effectiveSettings.v2FilterState?.[viewId] ?? null;
console.log("[FluentWorkspace] restoreFilterStateFromWorkspace", {
workspaceId: workspaceId,
viewId: viewId,
hasSaved: !!saved,
savedSearch: saved?.searchQuery ?? "",
savedProject: saved?.selectedProject ?? null,
hasAdvanced: !!saved?.advancedFilter,
groups: saved?.advancedFilter?.filterGroups?.length ?? 0,
});
if (saved) {
const savedState = saved;
return {
filters: savedState.filters || {},
selectedProject: savedState.selectedProject,
advancedFilter: savedState.advancedFilter || null,
viewMode: savedState.viewMode || "list",
shouldClearSearch: true, // Always clear searchQuery on workspace restore
};
} else {
// No saved state for this view in this workspace
return {
filters: {},
selectedProject: undefined,
advancedFilter: null,
viewMode: "list",
shouldClearSearch: true,
};
}
}
/**
* Get saved workspace ID from localStorage
*/
getSavedWorkspaceId(): string | null {
return localStorage.getItem("task-genius-v2-current-workspace");
}
/**
* Clear workspace state from localStorage
*/
clearWorkspaceState(): void {
localStorage.removeItem("task-genius-v2-current-workspace");
}
/**
* Clean up on unload
*/
onunload(): void {
// Save state before unload
this.saveWorkspaceLayout();
super.onunload();
}
}

View file

@ -18,6 +18,7 @@ export interface V2ViewState {
selectedProject?: string | null;
viewMode: 'list' | 'kanban' | 'tree' | 'calendar';
searchQuery?: string;
filterInputValue?: string;
filters?: any;
}