refactor(workspace): improve state management and persistence reliability

- Add snapshot-based filter state capture to prevent state corruption
- Implement immediate save for critical workspace operations
- Add recursive save protection with isSaving flag
- Introduce WORKSPACE_ONLY_KEYS for keys that never merge globally
- Add component visibility controls for sidebar sections
- Improve cache management with targeted invalidation
- Add activeViewId tracking for better view restoration
- Enhance error handling with user-facing notices
- Add backward compatibility for legacy sidebar component IDs
This commit is contained in:
Quorafind 2025-10-21 00:53:20 +08:00
parent 7d41359c6e
commit 422fdf04c3
6 changed files with 563 additions and 268 deletions

View file

@ -125,75 +125,94 @@ export class FluentSidebar extends Component {
this.renderNavigationItems(primarySection, this.primaryItems);
// Projects section
const projectsSection = content.createDiv({
cls: "fluent-sidebar-section",
});
const projectHeader = projectsSection.createDiv({
cls: "fluent-section-header",
});
projectHeader.createSpan({ text: t("Projects") });
// Button container for tree toggle and sort
const buttonContainer = projectHeader.createDiv({
cls: "fluent-project-header-buttons",
});
// Tree/List toggle button
const treeToggleBtn = buttonContainer.createDiv({
cls: "fluent-tree-toggle-btn",
attr: { "aria-label": t("Toggle tree/list view") },
});
// Load saved view mode preference
this.isTreeView =
this.plugin.app.loadLocalStorage(
"task-genius-project-view-mode",
) === "tree";
setIcon(treeToggleBtn, this.isTreeView ? "git-branch" : "list");
this.registerDomEvent(treeToggleBtn, "click", () => {
this.isTreeView = !this.isTreeView;
setIcon(treeToggleBtn, this.isTreeView ? "git-branch" : "list");
// Save preference
this.plugin.app.saveLocalStorage(
"task-genius-project-view-mode",
this.isTreeView ? "tree" : "list",
const isProjectsHidden =
this.plugin.workspaceManager?.isSidebarComponentHidden(
"projects-list",
);
// Update project list view mode
if (this.projectList) {
(this.projectList as ProjectList).setViewMode?.(
this.isTreeView,
if (!isProjectsHidden) {
const projectsSection = content.createDiv({
cls: "fluent-sidebar-section",
});
const projectHeader = projectsSection.createDiv({
cls: "fluent-section-header",
});
projectHeader.createSpan({ text: t("Projects") });
// Button container for tree toggle and sort
const buttonContainer = projectHeader.createDiv({
cls: "fluent-project-header-buttons",
});
// Tree/List toggle button
const treeToggleBtn = buttonContainer.createDiv({
cls: "fluent-tree-toggle-btn",
attr: { "aria-label": t("Toggle tree/list view") },
});
// Load saved view mode preference
this.isTreeView =
this.plugin.app.loadLocalStorage(
"task-genius-project-view-mode",
) === "tree";
setIcon(treeToggleBtn, this.isTreeView ? "git-branch" : "list");
this.registerDomEvent(treeToggleBtn, "click", () => {
this.isTreeView = !this.isTreeView;
setIcon(
treeToggleBtn,
this.isTreeView ? "git-branch" : "list",
);
}
});
// Save preference
this.plugin.app.saveLocalStorage(
"task-genius-project-view-mode",
this.isTreeView ? "tree" : "list",
);
// Update project list view mode
if (this.projectList) {
(this.projectList as ProjectList).setViewMode?.(
this.isTreeView,
);
}
});
// Sort button
const sortProjectBtn = buttonContainer.createDiv({
cls: "fluent-sort-project-btn",
attr: { "aria-label": t("Sort projects") },
});
setIcon(sortProjectBtn, "arrow-up-down");
// Sort button
const sortProjectBtn = buttonContainer.createDiv({
cls: "fluent-sort-project-btn",
attr: { "aria-label": t("Sort projects") },
});
setIcon(sortProjectBtn, "arrow-up-down");
// Pass sort button to project list for menu handling
this.registerDomEvent(sortProjectBtn, "click", () => {
(this.projectList as ProjectList).showSortMenu?.(sortProjectBtn);
});
// Pass sort button to project list for menu handling
this.registerDomEvent(sortProjectBtn, "click", () => {
(this.projectList as ProjectList).showSortMenu?.(
sortProjectBtn,
);
});
const projectListEl = projectsSection.createDiv();
this.projectList = new ProjectList(
projectListEl,
this.plugin,
this.onProjectSelect,
this.isTreeView,
);
// Add ProjectList as a child component
this.addChild(this.projectList);
const projectListEl = projectsSection.createDiv();
this.projectList = new ProjectList(
projectListEl,
this.plugin,
this.onProjectSelect,
this.isTreeView,
);
// Add ProjectList as a child component
this.addChild(this.projectList);
}
// Other views section
this.otherViewsSection = content.createDiv({
cls: "fluent-sidebar-section",
});
this.renderOtherViewsSection();
const isOtherViewsHidden =
this.plugin.workspaceManager?.isSidebarComponentHidden(
"other-views",
);
if (!isOtherViewsHidden) {
this.otherViewsSection = content.createDiv({
cls: "fluent-sidebar-section",
});
this.renderOtherViewsSection();
}
}
private renderRailMode() {
@ -232,53 +251,66 @@ export class FluentSidebar extends Component {
});
// Other view icons with overflow menu when > 5
const allOtherItems = this.computeOtherItems();
const visibleCount =
this.plugin?.settings?.fluentView?.fluentConfig
?.maxOtherViewsBeforeOverflow ?? 5;
const displayedOther: FluentTaskNavigationItem[] = allOtherItems.slice(
0,
visibleCount,
);
const remainingOther: FluentTaskNavigationItem[] =
allOtherItems.slice(visibleCount);
if (
!this.plugin.workspaceManager?.isSidebarComponentHidden(
"other-views",
)
) {
const allOtherItems = this.computeOtherItems();
const visibleCount =
this.plugin?.settings?.fluentView?.fluentConfig
?.maxOtherViewsBeforeOverflow ?? 5;
const displayedOther: FluentTaskNavigationItem[] =
allOtherItems.slice(0, visibleCount);
const remainingOther: FluentTaskNavigationItem[] =
allOtherItems.slice(visibleCount);
displayedOther.forEach((item: FluentTaskNavigationItem) => {
const btn = this.railEl!.createDiv({
cls: "fluent-rail-btn",
attr: { "aria-label": item.label, "data-view-id": item.id },
displayedOther.forEach((item: FluentTaskNavigationItem) => {
const btn = this.railEl!.createDiv({
cls: "fluent-rail-btn",
attr: {
"aria-label": item.label,
"data-view-id": item.id,
},
});
setIcon(btn, item.icon);
this.registerDomEvent(btn, "click", () => {
this.setActiveItem(item.id);
this.onNavigate(item.id);
});
// Add context menu handler for rail button
this.registerDomEvent(btn, "contextmenu", (e) => {
this.showViewContextMenu(e as MouseEvent, item.id);
});
});
setIcon(btn, item.icon);
this.registerDomEvent(btn, "click", () => {
this.setActiveItem(item.id);
this.onNavigate(item.id);
});
// Add context menu handler for rail button
this.registerDomEvent(btn, "contextmenu", (e) => {
this.showViewContextMenu(e as MouseEvent, item.id);
});
});
if (remainingOther.length > 0) {
const moreBtn = this.railEl.createDiv({
cls: "fluent-rail-btn",
attr: { "aria-label": t("More views") },
});
setIcon(moreBtn, "more-horizontal");
this.registerDomEvent(moreBtn, "click", (e) =>
this.showOtherViewsMenu(e as MouseEvent, remainingOther),
);
if (remainingOther.length > 0) {
const moreBtn = this.railEl.createDiv({
cls: "fluent-rail-btn",
attr: { "aria-label": t("More views") },
});
setIcon(moreBtn, "more-horizontal");
this.registerDomEvent(moreBtn, "click", (e) =>
this.showOtherViewsMenu(e as MouseEvent, remainingOther),
);
}
}
// Projects menu button
const projBtn = this.railEl.createDiv({
cls: "fluent-rail-btn",
attr: { "aria-label": t("Projects") },
});
setIcon(projBtn, "folder");
this.registerDomEvent(projBtn, "click", (e) =>
this.showProjectMenu(e as MouseEvent),
);
if (
!this.plugin.workspaceManager?.isSidebarComponentHidden(
"projects-list",
)
) {
const projBtn = this.railEl.createDiv({
cls: "fluent-rail-btn",
attr: { "aria-label": t("Projects") },
});
setIcon(projBtn, "folder");
this.registerDomEvent(projBtn, "click", (e) =>
this.showProjectMenu(e as MouseEvent),
);
}
// Add (New Task) button
const addBtn = this.railEl.createDiv({

View file

@ -1,4 +1,4 @@
import { App, Component, debounce } from "obsidian";
import { App, Component, Notice, debounce } from "obsidian";
import TaskProgressBarPlugin from "@/index";
import { RootFilterState } from "@/components/features/task/filter/ViewTaskFilter";
import { ViewMode } from "../components/FluentTopNavigation";
@ -10,6 +10,7 @@ export interface WorkspaceFilterSnapshot {
liveFilterState: RootFilterState | null;
viewMode: ViewMode;
shouldClearSearch: boolean;
activeViewId?: string;
}
export interface FilterSyncHandlers {
@ -24,6 +25,17 @@ export interface FilterSyncHandlers {
onAfterSync?: (snapshot: WorkspaceFilterSnapshot) => void;
}
interface FilterStatePersistPayload {
workspaceId: string;
viewId: string;
activeViewId: string;
filters: any;
selectedProject: string | undefined;
viewMode: ViewMode;
searchQuery: string;
advancedFilter: RootFilterState | null;
}
/**
* FluentWorkspaceStateManager - Manages workspace state persistence
*
@ -58,16 +70,14 @@ export class FluentWorkspaceStateManager extends Component {
* Save workspace layout (filter state and preferences)
*/
saveWorkspaceLayout(): void {
const workspaceId = this.getWorkspaceId();
if (!workspaceId) return;
const snapshot = this.captureFilterStateSnapshot();
if (!snapshot) return;
// Save filter state
this.saveFilterStateToWorkspace();
this.saveFilterStateToWorkspace(snapshot);
// Save current workspace ID to localStorage for persistence
localStorage.setItem(
"task-genius-fluent-current-workspace",
workspaceId
snapshot.workspaceId
);
}
@ -113,75 +123,210 @@ export class FluentWorkspaceStateManager extends Component {
}
/**
* Save filter state to workspace (debounced to avoid infinite loops)
* Capture a point-in-time snapshot of the filter state for persistence.
* @returns Snapshot payload or null if workspaceManager is unavailable
*/
saveFilterStateToWorkspace = debounce(
() => {
const workspaceId = this.getWorkspaceId();
const viewId = this.getCurrentViewId();
public captureFilterStateSnapshot(): FilterStatePersistPayload | null {
const workspaceId = this.getWorkspaceId();
const viewId = this.getCurrentViewId();
if (!this.plugin.workspaceManager || !workspaceId) return;
if (!this.plugin.workspaceManager || !workspaceId || !viewId) {
return null;
}
const viewState = this.getViewState();
const currentFilterState = this.getCurrentFilterState();
return {
workspaceId,
viewId,
activeViewId: viewId,
filters: structuredClone(viewState.filters || {}),
selectedProject: viewState.selectedProject,
viewMode: viewState.viewMode,
searchQuery: viewState.searchQuery,
advancedFilter: currentFilterState
? structuredClone(currentFilterState)
: null,
};
}
public getSavedActiveViewId(): string | null {
const workspaceId = this.getWorkspaceId();
if (!this.plugin.workspaceManager || !workspaceId) {
return null;
}
const effective =
this.plugin.workspaceManager.getEffectiveSettings(workspaceId);
const active = (effective as any)?.fluentActiveViewId;
return typeof active === "string" ? active : null;
}
/**
* Debounced worker that performs the actual save.
*/
private persistFilterState = debounce(
(payload: FilterStatePersistPayload) => {
if (!this.plugin.workspaceManager) {
return;
}
const {
workspaceId,
viewId,
activeViewId,
filters,
selectedProject,
viewMode,
searchQuery,
advancedFilter,
} = payload;
const effectiveSettings =
this.plugin.workspaceManager.getEffectiveSettings(workspaceId);
// Save current filter state
if (!effectiveSettings.fluentFilterState) {
effectiveSettings.fluentFilterState = {};
}
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.fluentFilterState[viewId] = {
filters,
selectedProject,
advancedFilter,
viewMode,
};
effectiveSettings.fluentFilterState[viewId] = payload;
if (activeViewId) {
effectiveSettings.fluentActiveViewId = activeViewId;
}
console.log("[FluentWorkspace] saveFilterStateToWorkspace", {
workspaceId: workspaceId,
viewId: viewId,
searchQuery: viewState.searchQuery,
selectedProject: viewState.selectedProject,
hasAdvanced: !!currentFilterState,
groups: (currentFilterState as any)?.filterGroups?.length ?? 0,
workspaceId,
viewId,
activeViewId,
searchQuery,
selectedProject,
hasAdvanced: !!advancedFilter,
groups: (advancedFilter 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,
workspaceId,
viewId,
})
)
.catch((e) =>
console.warn(
.catch((e) => {
console.error(
"[FluentWorkspace] failed to save overrides",
e
)
);
);
new Notice(
"Failed to save workspace state. Recent changes may be lost."
);
});
},
500,
true
500
);
/**
* Save filter state to workspace (debounced to avoid infinite loops)
*/
public saveFilterStateToWorkspace(
payload?: FilterStatePersistPayload,
): void {
const snapshot = payload ?? this.captureFilterStateSnapshot();
if (!snapshot) return;
this.persistFilterState(snapshot);
}
/**
* Save filter state immediately without debouncing.
* Used for critical saves like workspace switching.
* @param payload Optional snapshot to save, or captures current state if not provided
*/
public async saveFilterStateImmediately(
payload?: FilterStatePersistPayload,
): Promise<void> {
const snapshot = payload ?? this.captureFilterStateSnapshot();
if (!snapshot || !this.plugin.workspaceManager) {
return;
}
const {
workspaceId,
viewId,
activeViewId,
filters,
selectedProject,
viewMode,
advancedFilter,
} = snapshot;
const effectiveSettings =
this.plugin.workspaceManager.getEffectiveSettings(workspaceId);
if (!effectiveSettings.fluentFilterState) {
effectiveSettings.fluentFilterState = {};
}
effectiveSettings.fluentFilterState[viewId] = {
filters,
selectedProject,
advancedFilter,
viewMode,
};
if (activeViewId) {
effectiveSettings.fluentActiveViewId = activeViewId;
}
console.log(
"[FluentWorkspace] saveFilterStateImmediately",
{
workspaceId,
viewId,
activeViewId,
}
);
try {
await this.plugin.workspaceManager.saveOverridesQuietly(
workspaceId,
effectiveSettings
);
console.log(
"[FluentWorkspace] immediate save completed",
{ workspaceId, viewId }
);
} catch (e) {
console.error(
"[FluentWorkspace] immediate save failed",
e
);
new Notice(
"Failed to save workspace state. Recent changes may be lost."
);
}
}
/**
* Restore filter state from workspace
*/
restoreFilterStateFromWorkspace(): WorkspaceFilterSnapshot | null {
const workspaceId = this.getWorkspaceId();
const viewId = this.getCurrentViewId();
if (!this.plugin.workspaceManager || !workspaceId) return null;
const effectiveSettings =
this.plugin.workspaceManager.getEffectiveSettings(workspaceId);
const activeViewId =
((effectiveSettings as any)?.fluentActiveViewId as
| string
| undefined) ?? this.getCurrentViewId();
const viewId = activeViewId || this.getCurrentViewId();
const saved = effectiveSettings.fluentFilterState?.[viewId] ?? null;
@ -195,6 +340,7 @@ export class FluentWorkspaceStateManager extends Component {
liveFilterState: savedState.advancedFilter || null,
viewMode: savedState.viewMode || "list",
shouldClearSearch: true, // Always clear searchQuery on workspace restore
activeViewId: viewId,
};
} else {
// No saved state for this view in this workspace
@ -205,6 +351,7 @@ export class FluentWorkspaceStateManager extends Component {
liveFilterState: null,
viewMode: "list",
shouldClearSearch: true,
activeViewId: viewId,
};
}
}

View file

@ -1,7 +1,10 @@
import { App, Notice } from "obsidian";
import {
AnySidebarComponentType,
EffectiveSettings,
HiddenModulesConfig,
SidebarComponentType,
WORKSPACE_ONLY_KEYS,
WORKSPACE_SCOPED_KEYS,
WorkspaceData,
WorkspaceOverrides,
@ -24,6 +27,7 @@ export class WorkspaceManager {
private app: App;
private plugin: TaskProgressBarPlugin;
private effectiveCache: Map<string, EffectiveSettings> = new Map();
private isSaving = false;
constructor(plugin: TaskProgressBarPlugin) {
this.plugin = plugin;
@ -35,6 +39,7 @@ export class WorkspaceManager {
if (!this.plugin.settings.workspaces) {
return this.initializeWorkspaces();
}
return this.plugin.settings.workspaces;
}
@ -58,6 +63,11 @@ export class WorkspaceManager {
return this.plugin.settings.workspaces;
}
// Check if a key is workspace-only (never merged into global settings)
private isWorkspaceOnlyKey(key: string): boolean {
return WORKSPACE_ONLY_KEYS.includes(key as any);
}
// Ensure default workspace invariants
public ensureDefaultWorkspaceInvariant(): void {
const config = this.getWorkspacesConfig();
@ -83,9 +93,19 @@ export class WorkspaceManager {
// 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
const workspaceOnlyOverrides = Object.fromEntries(
Object.entries(defaultWs.settings).filter(([key]) =>
this.isWorkspaceOnlyKey(key),
),
) as WorkspaceOverrides;
// Merge any overrides into global settings while preserving workspace-only entries
this.mergeIntoGlobal(defaultWs.settings);
defaultWs.settings = {};
defaultWs.settings =
Object.keys(workspaceOnlyOverrides).length > 0
? workspaceOnlyOverrides
: {};
}
// Ensure active workspace exists
@ -100,7 +120,10 @@ export class WorkspaceManager {
// 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)) {
if (
WORKSPACE_SCOPED_KEYS.includes(key as any) &&
!this.isWorkspaceOnlyKey(key)
) {
(this.plugin.settings as any)[key] = structuredClone(
overrides[key as keyof WorkspaceOverrides],
);
@ -141,6 +164,7 @@ export class WorkspaceManager {
const effective: EffectiveSettings = { ...this.plugin.settings };
// Explicitly drop any global fluentFilterState to avoid cross-workspace leakage
(effective as any).fluentFilterState = undefined;
(effective as any).fluentActiveViewId = undefined;
// Apply workspace overrides if not default
if (id !== config.defaultWorkspaceId && workspace.settings) {
@ -160,6 +184,14 @@ export class WorkspaceManager {
workspace.settings.fluentFilterState,
);
}
if (
workspace.settings &&
workspace.settings.fluentActiveViewId !== undefined
) {
effective.fluentActiveViewId = structuredClone(
workspace.settings.fluentActiveViewId,
);
}
// Cache the result
this.effectiveCache.set(id, effective);
@ -174,8 +206,8 @@ export class WorkspaceManager {
const effValue = (effective as any)[key];
const globalValue = (this.plugin.settings as any)[key];
// fluentFilterState is workspace-only. Always persist it per-workspace when defined.
if (key === "fluentFilterState") {
// Workspace-only keys: always persist when defined
if (this.isWorkspaceOnlyKey(key)) {
if (effValue !== undefined) {
overrides[key] = structuredClone(effValue);
}
@ -215,7 +247,15 @@ export class WorkspaceManager {
}
// Clear the effective cache
public clearCache(): void {
public clearCache(...workspaceIds: Array<string | undefined>): void {
if (workspaceIds.length > 0) {
for (const id of workspaceIds) {
if (!id) continue;
this.effectiveCache.delete(id);
}
return;
}
this.effectiveCache.clear();
}
@ -237,7 +277,10 @@ export class WorkspaceManager {
public getActiveWorkspace(): WorkspaceData {
const config = this.getWorkspacesConfig();
const activeId = config.activeWorkspaceId || config.defaultWorkspaceId;
return config.byId[activeId] || config.byId[config.defaultWorkspaceId];
const workspace =
config.byId[activeId] || config.byId[config.defaultWorkspaceId];
return workspace;
}
// Set active workspace
@ -248,6 +291,7 @@ export class WorkspaceManager {
});
const config = this.getWorkspacesConfig();
const previousId = config.activeWorkspaceId;
if (!config.byId[workspaceId]) {
new Notice(`Workspace not found. Using default workspace.`);
@ -263,7 +307,7 @@ export class WorkspaceManager {
}
config.activeWorkspaceId = workspaceId;
this.clearCache();
this.clearCache(previousId, workspaceId);
await this.plugin.saveSettings();
@ -400,70 +444,93 @@ export class WorkspaceManager {
workspaceId: string,
effective: EffectiveSettings,
): Promise<void> {
const config = this.getWorkspacesConfig();
// Prevent recursive calls that could cause infinite loops
if (this.isSaving) {
console.warn(
"[TG-WORKSPACE] Recursive saveOverrides detected, skipping to prevent loop",
{ workspaceId }
);
return;
}
// Cannot save overrides to default workspace
if (workspaceId === config.defaultWorkspaceId) {
// For default, write directly to global settings EXCEPT fluentFilterState which is workspace-only
const changedKeys: string[] = [];
for (const key of WORKSPACE_SCOPED_KEYS) {
if (
effective[key] !== undefined &&
key !== "fluentFilterState"
) {
(this.plugin.settings as any)[key] = structuredClone(
effective[key],
);
changedKeys.push(key);
this.isSaving = true;
try {
const config = this.getWorkspacesConfig();
// Cannot save overrides to default workspace
if (workspaceId === config.defaultWorkspaceId) {
// For default, write directly to global settings EXCEPT workspace-only keys
const changedKeys: string[] = [];
for (const key of WORKSPACE_SCOPED_KEYS) {
if (
effective[key] !== undefined &&
!this.isWorkspaceOnlyKey(key)
) {
(this.plugin.settings as any)[key] = structuredClone(
effective[key],
);
changedKeys.push(key);
}
}
}
// Handle fluentFilterState specially for default workspace
if (effective.fluentFilterState !== undefined) {
const ws = config.byId[workspaceId];
ws.settings = (ws.settings || {}) as any;
(ws.settings as any).fluentFilterState = structuredClone(
effective.fluentFilterState,
// Handle fluentFilterState specially for default workspace
if (effective.fluentFilterState !== undefined) {
const ws = config.byId[workspaceId];
ws.settings = (ws.settings || {}) as any;
(ws.settings as any).fluentFilterState = structuredClone(
effective.fluentFilterState,
);
ws.updatedAt = Date.now();
changedKeys.push("fluentFilterState");
}
if (effective.fluentActiveViewId !== undefined) {
const ws = config.byId[workspaceId];
ws.settings = (ws.settings || {}) as any;
(ws.settings as any).fluentActiveViewId = structuredClone(
effective.fluentActiveViewId,
);
ws.updatedAt = Date.now();
changedKeys.push("fluentActiveViewId");
}
console.log("[TG-WORKSPACE] saveOverrides(default)", {
workspaceId,
changedKeys,
});
this.clearCache();
await this.plugin.saveSettings();
// Emit overrides saved for UI to react; also emit SETTINGS_CHANGED for global changes
emitWorkspaceOverridesSaved(
this.app,
workspaceId,
changedKeys.length ? changedKeys : undefined,
);
ws.updatedAt = Date.now();
changedKeys.push("fluentFilterState");
emit(this.app, Events.SETTINGS_CHANGED);
return;
}
console.log("[TG-WORKSPACE] saveOverrides(default)", {
const workspace = config.byId[workspaceId];
if (!workspace) {
return;
}
// Calculate overrides
const overrides = this.toOverrides(effective);
const changedKeys = Object.keys(overrides);
console.log("[TG-WORKSPACE] saveOverrides", {
workspaceId,
changedKeys,
});
workspace.settings = overrides;
workspace.updatedAt = Date.now();
this.clearCache();
await this.plugin.saveSettings();
// Emit overrides saved for UI to react; also emit SETTINGS_CHANGED for global changes
emitWorkspaceOverridesSaved(
this.app,
workspaceId,
changedKeys.length ? changedKeys : undefined,
);
emitWorkspaceOverridesSaved(this.app, workspaceId, changedKeys);
emit(this.app, Events.SETTINGS_CHANGED);
return;
} finally {
this.isSaving = false;
}
const workspace = config.byId[workspaceId];
if (!workspace) {
return;
}
// Calculate overrides
const overrides = this.toOverrides(effective);
const changedKeys = Object.keys(overrides);
console.log("[TG-WORKSPACE] saveOverrides", {
workspaceId,
changedKeys,
});
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
@ -475,11 +542,11 @@ export class WorkspaceManager {
// Cannot save overrides to default workspace
if (workspaceId === config.defaultWorkspaceId) {
// For default, write directly to global settings EXCEPT fluentFilterState which is workspace-only
// For default, write directly to global settings EXCEPT workspace-only keys
for (const key of WORKSPACE_SCOPED_KEYS) {
if (
effective[key] !== undefined &&
key !== "fluentFilterState"
!this.isWorkspaceOnlyKey(key)
) {
(this.plugin.settings as any)[key] = structuredClone(
effective[key],
@ -495,6 +562,14 @@ export class WorkspaceManager {
);
ws.updatedAt = Date.now();
}
if (effective.fluentActiveViewId !== undefined) {
const ws = config.byId[workspaceId];
ws.settings = (ws.settings || {}) as any;
(ws.settings as any).fluentActiveViewId = structuredClone(
effective.fluentActiveViewId,
);
ws.updatedAt = Date.now();
}
console.log("[TG-WORKSPACE] saveOverridesQuietly(default)", {
workspaceId,
keys: WORKSPACE_SCOPED_KEYS.filter(
@ -722,19 +797,35 @@ export class WorkspaceManager {
return workspace.settings.hiddenModules.views.includes(viewId);
}
/**
* Normalize sidebar component ID for backward compatibility
* Maps legacy component IDs to current ones
* @param componentId - The sidebar component ID to normalize
* @returns Normalized component ID, or null if the component doesn't exist
*/
private normalizeSidebarComponentId(
componentId: AnySidebarComponentType,
): SidebarComponentType | null {
// Mapping from legacy/any ID to current ID
const mapping: Record<string, SidebarComponentType | null> = {
"projects-list": "projects-list",
"other-views": "other-views",
"view-switcher": "other-views", // Legacy name mapped to new name
"tags-list": null, // Never implemented, ignore
"top-views": null, // Only for old TaskView (not implemented), ignore
"bottom-views": null, // Only for old TaskView (not implemented), ignore
};
return mapping[componentId] ?? null;
}
/**
* Check if a sidebar component is hidden in the specified workspace
* @param componentId - The sidebar component ID to check
* @param componentId - The sidebar component ID to check (accepts legacy IDs)
* @param workspaceId - Optional workspace ID, defaults to active workspace
* @returns true if the sidebar component is hidden
*/
public isSidebarComponentHidden(
componentId:
| "projects-list"
| "tags-list"
| "view-switcher"
| "top-views"
| "bottom-views",
componentId: AnySidebarComponentType,
workspaceId?: string,
): boolean {
const workspace = workspaceId
@ -745,8 +836,15 @@ export class WorkspaceManager {
return false;
}
// Normalize the component ID to handle legacy names
const normalizedId = this.normalizeSidebarComponentId(componentId);
if (normalizedId === null) {
// Legacy component that doesn't exist, treat as not hidden
return false;
}
return workspace.settings.hiddenModules.sidebarComponents.includes(
componentId,
normalizedId,
);
}
@ -857,17 +955,11 @@ export class WorkspaceManager {
/**
* Set hidden sidebar components for a workspace
* @param componentIds - Array of sidebar component IDs to hide
* @param componentIds - Array of sidebar component IDs to hide (accepts legacy IDs)
* @param workspaceId - Optional workspace ID, defaults to active workspace
*/
public async setHiddenSidebarComponents(
componentIds: Array<
| "projects-list"
| "tags-list"
| "view-switcher"
| "top-views"
| "bottom-views"
>,
componentIds: Array<AnySidebarComponentType>,
workspaceId?: string,
): Promise<void> {
const workspace = workspaceId
@ -876,9 +968,14 @@ export class WorkspaceManager {
if (!workspace) return;
// Normalize component IDs and filter out invalid ones
const normalizedIds = componentIds
.map((id) => this.normalizeSidebarComponentId(id))
.filter((id): id is SidebarComponentType => id !== null);
// Ensure complete initialization and get the initialized object
const hiddenModules = this.ensureHiddenModulesInitialized(workspace);
hiddenModules.sidebarComponents = [...componentIds];
hiddenModules.sidebarComponents = normalizedIds;
workspace.updatedAt = Date.now();
this.clearCache();

View file

@ -128,7 +128,7 @@ export function renderWorkspaceSettingsTab(
});
}
})
.addButton((button) => {
.addExtraButton((button) => {
button
.setIcon("settings-2")
.setTooltip(t("Configure hidden modules"))
@ -140,7 +140,7 @@ export function renderWorkspaceSettingsTab(
);
});
})
.addButton((button) => {
.addExtraButton((button) => {
button
.setIcon("edit")
.setTooltip(t("Rename"))
@ -148,7 +148,7 @@ export function renderWorkspaceSettingsTab(
showRenameWorkspaceDialog(settingTab, workspace);
});
})
.addButton((button) => {
.addExtraButton((button) => {
if (isDefaultWs) {
button
.setIcon("trash")
@ -262,7 +262,7 @@ function getAvailableModules(plugin: TaskProgressBarPlugin): {
}),
);
// Define sidebar component modules
// Define sidebar component modules (Fluent interface only)
const sidebarComponents: ModuleDefinition[] = [
{
id: "projects-list",
@ -271,29 +271,11 @@ function getAvailableModules(plugin: TaskProgressBarPlugin): {
type: "sidebar" as const,
},
{
id: "tags-list",
name: t("Tags List"),
icon: "tag",
type: "sidebar" as const,
},
{
id: "view-switcher",
name: t("View Switcher"),
id: "other-views",
name: t("Other Views"),
icon: "layout-list",
type: "sidebar" as const,
},
{
id: "top-views",
name: t("Top Views Area"),
icon: "layout-top",
type: "sidebar" as const,
},
{
id: "bottom-views",
name: t("Bottom Views Area"),
icon: "layout-bottom",
type: "sidebar" as const,
},
];
// Define feature component modules
@ -475,8 +457,9 @@ function renderHiddenModulesConfig(
break;
}
const refreshed =
plugin.workspaceManager?.getWorkspace(workspace.id);
const refreshed = plugin.workspaceManager?.getWorkspace(
workspace.id,
);
if (refreshed?.settings?.hiddenModules) {
hiddenModules = refreshed.settings.hiddenModules;
} else {
@ -487,18 +470,16 @@ function renderHiddenModulesConfig(
};
}
hiddenList = newHiddenList;
// Update local state from the refreshed data (after normalization)
switch (moduleType) {
case "views":
hiddenModules.views = newHiddenList;
hiddenList = hiddenModules.views || [];
break;
case "sidebarComponents":
hiddenModules.sidebarComponents =
newHiddenList as SidebarComponentType[];
hiddenList = hiddenModules.sidebarComponents || [];
break;
case "features":
hiddenModules.features =
newHiddenList as FeatureComponentType[];
hiddenList = hiddenModules.features || [];
break;
}
@ -512,7 +493,7 @@ function renderHiddenModulesConfig(
"{{hidden}}/{{total}} hidden",
{
interpolation: {
hidden: newHiddenList.length.toString(),
hidden: hiddenList.length.toString(),
total: totalCount.toString(),
},
},
@ -530,12 +511,12 @@ function renderHiddenModulesConfig(
};
checkbox.addEventListener("change", () => {
void toggleVisibility();
toggleVisibility();
});
itemEl.addEventListener("click", (e) => {
if (e.target !== checkbox) {
checkbox.checked = !checkbox.checked;
void toggleVisibility();
toggleVisibility();
}
});
});

View file

@ -270,6 +270,9 @@ export class FluentTaskView extends ItemView {
await this.workspaceStateManager.applyWorkspaceSettings();
const restored =
this.workspaceStateManager.restoreFilterStateFromWorkspace();
if (restored?.activeViewId) {
this.currentViewId = restored.activeViewId;
}
this.workspaceStateManager.syncFilterState(restored, {
setLiveFilterState: (state) => {
this.liveFilterState = state;
@ -848,8 +851,14 @@ export class FluentTaskView extends ItemView {
this.registerEvent(
onWorkspaceSwitched(this.app, async (payload) => {
if (payload.workspaceId !== this.workspaceId) {
// Save current workspace state
this.workspaceStateManager.saveWorkspaceLayout();
// Save current workspace state immediately (before switching)
const snapshot =
this.workspaceStateManager.captureFilterStateSnapshot();
if (snapshot) {
await this.workspaceStateManager.saveFilterStateImmediately(
snapshot
);
}
// Switch to new workspace
this.workspaceId = payload.workspaceId;
@ -858,9 +867,12 @@ export class FluentTaskView extends ItemView {
// Apply new workspace settings
await this.workspaceStateManager.applyWorkspaceSettings();
// Restore filter state
// Restore filter state (includes activeViewId)
const restored =
this.workspaceStateManager.restoreFilterStateFromWorkspace();
if (restored?.activeViewId) {
this.currentViewId = restored.activeViewId;
}
this.workspaceStateManager.syncFilterState(restored, {
setLiveFilterState: (state) => {
this.liveFilterState = state;

View file

@ -44,6 +44,7 @@ export interface WorkspaceOverrides {
viewMode?: string;
}
>;
fluentActiveViewId?: string;
// Hidden modules configuration
hiddenModules?: HiddenModulesConfig;
@ -61,13 +62,28 @@ export interface HiddenModulesConfig {
features?: FeatureComponentType[];
}
/** Sidebar component types that can be hidden */
/** Sidebar component types that can be hidden (Fluent interface) */
export type SidebarComponentType =
| "projects-list"
| "tags-list"
| "view-switcher"
| "top-views"
| "bottom-views";
| "projects-list" // Fluent: Projects list section
| "other-views"; // Fluent: Other views section
/**
* Legacy sidebar component types (deprecated, kept for backward compatibility)
* These will be silently ignored or mapped when reading old workspace settings
*/
export type LegacySidebarComponentType =
| "tags-list" // Never implemented
| "view-switcher" // Legacy name, replaced by "other-views"
| "top-views" // Only for old TaskView (not implemented)
| "bottom-views"; // Only for old TaskView (not implemented)
/**
* All sidebar component types including legacy ones
* Used internally for reading old settings
*/
export type AnySidebarComponentType =
| SidebarComponentType
| LegacySidebarComponentType;
/** Feature component types that can be hidden */
export type FeatureComponentType =
@ -115,11 +131,21 @@ export const WORKSPACE_SCOPED_KEYS = [
"customProjectGroupsAndNames",
"tagCustomOrder",
"fluentFilterState",
"fluentActiveViewId",
"hiddenModules",
] as const;
export type WorkspaceScopedKey = (typeof WORKSPACE_SCOPED_KEYS)[number];
// Workspace-only keys (stored per-workspace, never merged into global settings)
export const WORKSPACE_ONLY_KEYS = [
"fluentFilterState",
"fluentActiveViewId",
"hiddenModules",
] as const;
export type WorkspaceOnlyKey = (typeof WORKSPACE_ONLY_KEYS)[number];
// Global-only keys (cannot be overridden)
export const GLOBAL_ONLY_KEYS = [
"autoRun",