fix(editor-extensions): resolve transaction conflicts between workflow and date managers

- Add transaction annotation checks to prevent workflow/date manager conflicts
- Improve bounds checking and error handling in date manager operations
- Add safety checks for document position calculations
- Remove verbose console logging for cleaner output
- Fix code formatting and trailing comma inconsistencies
- Enhance transaction filter coordination to prevent duplicate operations
This commit is contained in:
Quorafind 2025-10-13 10:06:33 +08:00
parent 37c9769593
commit cf5334f3ba
12 changed files with 670 additions and 308 deletions

View file

@ -232,6 +232,19 @@ export class EditorState {
};
},
};
// Add transactionExtender mock for tests
static transactionExtender = {
of: (
f: (
tr: Transaction
) => TransactionSpec | readonly TransactionSpec[] | null
) => {
return {
extend: f,
};
},
};
}
export class Annotation<T> {

View file

@ -30,7 +30,7 @@ export class FluentSidebar extends Component {
private railEl: HTMLElement | null = null;
private primaryItems: FluentTaskNavigationItem[] = [
{id: "inbox", label: t("Inbox"), icon: "inbox", type: "primary"},
{ id: "inbox", label: t("Inbox"), icon: "inbox", type: "primary" },
{
id: "today",
label: t("Today"),
@ -43,7 +43,7 @@ export class FluentSidebar extends Component {
icon: "calendar",
type: "primary",
},
{id: "flagged", label: t("Flagged"), icon: "flag", type: "primary"},
{ id: "flagged", label: t("Flagged"), icon: "flag", type: "primary" },
];
private otherItems: FluentTaskNavigationItem[] = [
@ -53,14 +53,14 @@ export class FluentSidebar extends Component {
icon: "calendar",
type: "other",
},
{id: "gantt", label: t("Gantt"), icon: "git-branch", type: "other"},
{ id: "gantt", label: t("Gantt"), icon: "git-branch", type: "other" },
{
id: "review",
label: t("Review"),
icon: "check-square",
type: "other",
},
{id: "tags", label: t("Tags"), icon: "tag", type: "other"},
{ id: "tags", label: t("Tags"), icon: "tag", type: "other" },
];
constructor(
@ -68,7 +68,7 @@ export class FluentSidebar extends Component {
plugin: TaskProgressBarPlugin,
private onNavigate: (viewId: string) => void,
private onProjectSelect: (projectId: string) => void,
collapsed = false
collapsed = false,
) {
super();
this.containerEl = containerEl;
@ -103,7 +103,8 @@ export class FluentSidebar extends Component {
this.workspaceSelector = new WorkspaceSelector(
workspaceSelectorEl,
this.plugin,
(workspaceId: string) => this.handleWorkspaceChange(workspaceId)
(workspaceId: string) =>
this.handleWorkspaceChange(workspaceId),
);
}
@ -112,10 +113,10 @@ export class FluentSidebar extends Component {
cls: "fluent-new-task-btn",
text: t("New Task"),
});
setIcon(newTaskBtn.createDiv({cls: "fluent-new-task-icon"}), "plus");
newTaskBtn.addEventListener("click", () => {
this.onNavigate("new-task");
});
setIcon(newTaskBtn.createDiv({ cls: "fluent-new-task-icon" }), "plus");
this.registerDomEvent(newTaskBtn, "click", () =>
this.onNavigate("new-task"),
);
// Main navigation area
const content = this.containerEl.createDiv({
@ -136,7 +137,7 @@ export class FluentSidebar extends Component {
cls: "fluent-section-header",
});
projectHeader.createSpan({text: t("Projects")});
projectHeader.createSpan({ text: t("Projects") });
// Button container for tree toggle and sort
const buttonContainer = projectHeader.createDiv({
@ -146,22 +147,22 @@ export class FluentSidebar extends Component {
// Tree/List toggle button
const treeToggleBtn = buttonContainer.createDiv({
cls: "fluent-tree-toggle-btn",
attr: {"aria-label": t("Toggle tree/list view")},
attr: { "aria-label": t("Toggle tree/list view") },
});
// Load saved view mode preference
this.isTreeView =
this.plugin.app.loadLocalStorage(
"task-genius-project-view-mode"
"task-genius-project-view-mode",
) === "tree";
setIcon(treeToggleBtn, this.isTreeView ? "git-branch" : "list");
treeToggleBtn.addEventListener("click", () => {
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"
this.isTreeView ? "tree" : "list",
);
// Update project list view mode
if (this.projectList) {
@ -172,12 +173,12 @@ export class FluentSidebar extends Component {
// Sort button
const sortProjectBtn = buttonContainer.createDiv({
cls: "fluent-sort-project-btn",
attr: {"aria-label": t("Sort projects")},
attr: { "aria-label": t("Sort projects") },
});
setIcon(sortProjectBtn, "arrow-up-down");
// Pass sort button to project list for menu handling
sortProjectBtn.addEventListener("click", () => {
this.registerDomEvent(sortProjectBtn, "click", () => {
(this.projectList as any).showSortMenu?.(sortProjectBtn);
});
@ -186,7 +187,7 @@ export class FluentSidebar extends Component {
projectListEl,
this.plugin,
this.onProjectSelect,
this.isTreeView
this.isTreeView,
);
// Add ProjectList as a child component
this.addChild(this.projectList);
@ -209,26 +210,26 @@ export class FluentSidebar extends Component {
// Workspace menu button
const wsBtn = this.railEl.createDiv({
cls: "fluent-rail-btn",
attr: {"aria-label": t("Workspace")},
attr: { "aria-label": t("Workspace") },
});
setIcon(wsBtn, "layers");
wsBtn.addEventListener("click", (e) =>
this.showWorkspaceMenuWithManager(e as MouseEvent)
this.registerDomEvent(wsBtn, "click", (e) =>
this.showWorkspaceMenuWithManager(e as MouseEvent),
);
// Primary view icons
this.primaryItems.forEach((item) => {
const btn = this.railEl!.createDiv({
cls: "fluent-rail-btn",
attr: {"aria-label": item.label, "data-view-id": item.id},
attr: { "aria-label": item.label, "data-view-id": item.id },
});
setIcon(btn, item.icon);
btn.addEventListener("click", () => {
this.registerDomEvent(btn, "click", () => {
this.setActiveItem(item.id);
this.onNavigate(item.id);
});
// Add context menu handler for rail button
btn.addEventListener("contextmenu", (e) => {
this.registerDomEvent(btn, "contextmenu", (e) => {
this.showViewContextMenu(e as MouseEvent, item.id);
});
});
@ -240,7 +241,7 @@ export class FluentSidebar extends Component {
?.maxOtherViewsBeforeOverflow ?? 5;
const displayedOther: FluentTaskNavigationItem[] = allOtherItems.slice(
0,
visibleCount
visibleCount,
);
const remainingOther: FluentTaskNavigationItem[] =
allOtherItems.slice(visibleCount);
@ -248,15 +249,15 @@ export class FluentSidebar extends Component {
displayedOther.forEach((item: FluentTaskNavigationItem) => {
const btn = this.railEl!.createDiv({
cls: "fluent-rail-btn",
attr: {"aria-label": item.label, "data-view-id": item.id},
attr: { "aria-label": item.label, "data-view-id": item.id },
});
setIcon(btn, item.icon);
btn.addEventListener("click", () => {
this.registerDomEvent(btn, "click", () => {
this.setActiveItem(item.id);
this.onNavigate(item.id);
});
// Add context menu handler for rail button
btn.addEventListener("contextmenu", (e) => {
this.registerDomEvent(btn, "contextmenu", (e) => {
this.showViewContextMenu(e as MouseEvent, item.id);
});
});
@ -264,31 +265,31 @@ export class FluentSidebar extends Component {
if (remainingOther.length > 0) {
const moreBtn = this.railEl!.createDiv({
cls: "fluent-rail-btn",
attr: {"aria-label": t("More views")},
attr: { "aria-label": t("More views") },
});
setIcon(moreBtn, "more-horizontal");
moreBtn.addEventListener("click", (e) =>
this.showOtherViewsMenu(e as MouseEvent, remainingOther)
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")},
attr: { "aria-label": t("Projects") },
});
setIcon(projBtn, "folder");
projBtn.addEventListener("click", (e) =>
this.showProjectMenu(e as MouseEvent)
this.registerDomEvent(projBtn, "click", (e) =>
this.showProjectMenu(e as MouseEvent),
);
// Add (New Task) button
const addBtn = this.railEl!.createDiv({
cls: "fluent-rail-btn",
attr: {"aria-label": t("New Task")},
attr: { "aria-label": t("New Task") },
});
setIcon(addBtn, "plus");
addBtn.addEventListener("click", () => this.onNavigate("new-task"));
this.registerDomEvent(addBtn, "click", () => this.onNavigate("new-task"));
}
private renderOtherViewsSection() {
@ -310,21 +311,21 @@ export class FluentSidebar extends Component {
?.maxOtherViewsBeforeOverflow ?? 5;
const displayedOther: FluentTaskNavigationItem[] = allOtherItems.slice(
0,
visibleCount
visibleCount,
);
const remainingOther: FluentTaskNavigationItem[] =
allOtherItems.slice(visibleCount);
otherHeader.createSpan({text: t("Other Views")});
otherHeader.createSpan({ text: t("Other Views") });
if (remainingOther.length > 0) {
const moreBtn = otherHeader.createDiv({
cls: "fluent-section-action",
attr: {"aria-label": t("More views")},
attr: { "aria-label": t("More views") },
});
setIcon(moreBtn, "more-horizontal");
moreBtn.addEventListener("click", (e) =>
this.showOtherViewsMenu(e as MouseEvent, remainingOther)
this.registerDomEvent(moreBtn, "click", (e) =>
this.showOtherViewsMenu(e as MouseEvent, remainingOther),
);
}
@ -383,19 +384,19 @@ export class FluentSidebar extends Component {
onWorkspaceSwitched(this.plugin.app, (payload) => {
this.currentWorkspaceId = payload.workspaceId;
this.render();
})
}),
);
this.registerEvent(
onWorkspaceDeleted(this.plugin.app, () => {
this.render();
})
}),
);
this.registerEvent(
onWorkspaceCreated(this.plugin.app, () => {
this.render();
})
}),
);
}
}
@ -463,14 +464,14 @@ export class FluentSidebar extends Component {
constructor(
private plugin: TaskProgressBarPlugin,
private onCreated: () => void
private onCreated: () => void,
) {
super(plugin.app);
}
onOpen() {
const {contentEl} = this;
contentEl.createEl("h2", {text: t("Create New Workspace")});
const { contentEl } = this;
contentEl.createEl("h2", { text: t("Create New Workspace") });
const inputContainer = contentEl.createDiv();
inputContainer.createEl("label", {
@ -495,14 +496,14 @@ export class FluentSidebar extends Component {
const name = this.nameInput.value.trim();
if (name && this.plugin.workspaceManager) {
await this.plugin.workspaceManager.createWorkspace(
name
name,
);
new Notice(
t('Workspace "{{name}}" created', {
interpolation: {
name: name,
},
})
}),
);
this.onCreated();
this.close();
@ -519,7 +520,7 @@ export class FluentSidebar extends Component {
}
onClose() {
const {contentEl} = this;
const { contentEl } = this;
contentEl.empty();
}
}
@ -538,7 +539,7 @@ export class FluentSidebar extends Component {
const tempList: any = new ProjectList(
temp as any,
this.plugin,
this.onProjectSelect
this.onProjectSelect,
);
if (typeof tempList.getProjects === "function") {
projects = tempList.getProjects();
@ -557,7 +558,10 @@ export class FluentSidebar extends Component {
menu.showAtMouseEvent(event);
}
private showOtherViewsMenu(event: MouseEvent, items: FluentTaskNavigationItem[]) {
private showOtherViewsMenu(
event: MouseEvent,
items: FluentTaskNavigationItem[],
) {
const menu = new Menu();
items.forEach((it: FluentTaskNavigationItem) => {
menu.addItem((mi) => {
@ -580,7 +584,7 @@ export class FluentSidebar extends Component {
// Check if this is a primary view
const isPrimaryView = this.primaryItems.some(
(item) => item.id === viewId
(item) => item.id === viewId,
);
// Open in new tab
@ -607,7 +611,7 @@ export class FluentSidebar extends Component {
if (viewId === "habit") {
(this.plugin.app as any).setting.open();
(this.plugin.app as any).setting.openTabById(
this.plugin.manifest.id
this.plugin.manifest.id,
);
setTimeout(() => {
if (this.plugin.settingTab) {
@ -619,7 +623,7 @@ export class FluentSidebar extends Component {
// Normal handling for other views
const view = this.plugin.settings.viewConfiguration.find(
(v) => v.id === viewId
(v) => v.id === viewId,
);
if (!view) {
return;
@ -632,16 +636,16 @@ export class FluentSidebar extends Component {
currentRules,
(
updatedView: ViewConfig,
updatedRules: ViewFilterRule
updatedRules: ViewFilterRule,
) => {
const currentIndex =
this.plugin.settings.viewConfiguration.findIndex(
(v) => v.id === updatedView.id
(v) => v.id === updatedView.id,
);
if (currentIndex !== -1) {
this.plugin.settings.viewConfiguration[
currentIndex
] = {
] = {
...updatedView,
filterRules: updatedRules,
};
@ -653,10 +657,10 @@ export class FluentSidebar extends Component {
// Trigger view config changed event
this.plugin.app.workspace.trigger(
"task-genius:view-config-changed",
{reason: "edit", viewId: viewId}
{ reason: "edit", viewId: viewId },
);
}
}
},
).open();
});
});
@ -670,7 +674,7 @@ export class FluentSidebar extends Component {
.onClick(() => {
const view =
this.plugin.settings.viewConfiguration.find(
(v) => v.id === viewId
(v) => v.id === viewId,
);
if (!view) {
return;
@ -683,18 +687,18 @@ export class FluentSidebar extends Component {
null, // null for create mode
(
createdView: ViewConfig,
createdRules: ViewFilterRule
createdRules: ViewFilterRule,
) => {
if (
!this.plugin.settings.viewConfiguration.some(
(v) => v.id === createdView.id
(v) => v.id === createdView.id,
)
) {
this.plugin.settings.viewConfiguration.push(
{
...createdView,
filterRules: createdRules,
}
},
);
this.plugin.saveSettings();
// Re-render the sidebar to show the new view
@ -705,20 +709,20 @@ export class FluentSidebar extends Component {
{
reason: "create",
viewId: createdView.id,
}
},
);
new Notice(
t("View copied successfully: ") +
createdView.name
createdView.name,
);
} else {
new Notice(
t("Error: View ID already exists.")
t("Error: View ID already exists."),
);
}
},
view, // Pass current view as copy source
view.id
view.id,
).open();
});
});
@ -729,7 +733,7 @@ export class FluentSidebar extends Component {
.onClick(() => {
const view =
this.plugin.settings.viewConfiguration.find(
(v) => v.id === viewId
(v) => v.id === viewId,
);
if (!view) {
return;
@ -745,7 +749,7 @@ export class FluentSidebar extends Component {
// Trigger view config changed event
this.plugin.app.workspace.trigger(
"task-genius:view-config-changed",
{reason: "visibility", viewId: viewId}
{ reason: "visibility", viewId: viewId },
);
});
});
@ -753,7 +757,7 @@ export class FluentSidebar extends Component {
// Delete (for custom views only)
const view = this.plugin.settings.viewConfiguration.find(
(v) => v.id === viewId
(v) => v.id === viewId,
);
if (view?.type === "custom") {
menu.addSeparator();
@ -764,7 +768,7 @@ export class FluentSidebar extends Component {
.onClick(() => {
this.plugin.settings.viewConfiguration =
this.plugin.settings.viewConfiguration.filter(
(v) => v.id !== viewId
(v) => v.id !== viewId,
);
this.plugin.saveSettings();
// Re-render based on current mode
@ -776,7 +780,7 @@ export class FluentSidebar extends Component {
// Trigger view config changed event
this.plugin.app.workspace.trigger(
"task-genius:view-config-changed",
{reason: "delete", viewId: viewId}
{ reason: "delete", viewId: viewId },
);
new Notice(t("View deleted: ") + view.name);
});
@ -788,15 +792,15 @@ export class FluentSidebar extends Component {
private renderNavigationItems(
containerEl: HTMLElement,
items: FluentTaskNavigationItem[]
items: FluentTaskNavigationItem[],
) {
const list = containerEl.createDiv({cls: "fluent-navigation-list"});
const list = containerEl.createDiv({ cls: "fluent-navigation-list" });
items.forEach((item) => {
const itemEl = list.createDiv({
cls: "fluent-navigation-item",
attr: {"data-view-id": item.id},
attr: { "data-view-id": item.id },
});
const icon = itemEl.createDiv({cls: "fluent-navigation-icon"});
const icon = itemEl.createDiv({ cls: "fluent-navigation-icon" });
setIcon(icon, item.icon);
itemEl.createSpan({
cls: "fluent-navigation-label",
@ -808,12 +812,12 @@ export class FluentSidebar extends Component {
text: String(item.badge),
});
}
itemEl.addEventListener("click", () => {
this.registerDomEvent(itemEl, "click", () => {
this.setActiveItem(item.id);
this.onNavigate(item.id);
});
// Add context menu handler
itemEl.addEventListener("contextmenu", (e) => {
this.registerDomEvent(itemEl, "contextmenu", (e) => {
this.showViewContextMenu(e as MouseEvent, item.id);
});
});
@ -823,14 +827,14 @@ export class FluentSidebar extends Component {
// Clear active state from both full navigation items and rail buttons
this.containerEl
.querySelectorAll(
".fluent-navigation-item, .fluent-rail-btn[data-view-id]"
".fluent-navigation-item, .fluent-rail-btn[data-view-id]",
)
.forEach((el) => {
el.removeClass("is-active");
});
// Apply to any element that carries this view id (works in both modes)
const activeEls = this.containerEl.querySelectorAll(
`[data-view-id="${viewId}"]`
`[data-view-id="${viewId}"]`,
);
activeEls.forEach((el) => el.addClass("is-active"));
}

View file

@ -30,7 +30,7 @@ export class TopNavigation extends Component {
private onSortClick: () => void,
private onSettingsClick: () => void,
availableModes?: ViewMode[],
private onToggleSidebar?: () => void
private onToggleSidebar?: () => void,
) {
super();
this.containerEl = containerEl;
@ -75,12 +75,12 @@ export class TopNavigation extends Component {
// Hide entire navigation if no view modes are available
if (this.availableModes.length === 0) {
this.containerEl.style.display = "none";
this.containerEl.hide();
return;
}
// Show navigation when modes are available
this.containerEl.style.display = "";
this.containerEl.show();
// Left section - Hamburger menu (mobile) and Search
const leftSection = this.containerEl.createDiv({
@ -125,8 +125,8 @@ export class TopNavigation extends Component {
if (this.notificationCount === 0) {
badge.hide();
}
notificationBtn.addEventListener("click", (e) =>
this.showNotifications(e)
this.registerDomEvent(notificationBtn, "click", (e) =>
this.showNotifications(e),
);
// Settings button
@ -134,14 +134,14 @@ export class TopNavigation extends Component {
cls: "fluent-nav-icon-button",
});
setIcon(settingsBtn, "settings");
settingsBtn.addEventListener("click", () => this.onSettingsClick());
this.registerDomEvent(settingsBtn, "click", () => this.onSettingsClick());
}
private createViewTab(
container: HTMLElement,
mode: ViewMode,
icon: string,
label: string
label: string,
) {
const tab = container.createEl("button", {
cls: ["fluent-view-tab", "clickable-icon"],
@ -155,7 +155,7 @@ export class TopNavigation extends Component {
setIcon(tab.createDiv({ cls: "fluent-view-tab-icon" }), icon);
tab.createSpan({ text: label });
tab.addEventListener("click", () => {
this.registerDomEvent(tab, "click", () => {
this.setViewMode(mode);
this.onViewModeChange(mode);
});
@ -169,7 +169,7 @@ export class TopNavigation extends Component {
});
const activeTab = this.containerEl.querySelector(
`[data-mode="${mode}"]`
`[data-mode="${mode}"]`,
);
if (activeTab) {
activeTab.addClass("is-active");
@ -206,7 +206,7 @@ export class TopNavigation extends Component {
} else {
menu.addItem((item) => {
item.setTitle(
`${overdueTasks.length} overdue tasks`
`${overdueTasks.length} overdue tasks`,
).setDisabled(true);
});
@ -220,7 +220,7 @@ export class TopNavigation extends Component {
new Notice(
t("Task: {{content}}", {
content: task.content || "",
})
}),
);
});
});
@ -232,7 +232,7 @@ export class TopNavigation extends Component {
private updateNotificationBadge() {
const badge = this.containerEl.querySelector(
".fluent-notification-badge"
".fluent-notification-badge",
);
if (badge instanceof HTMLElement) {
badge.textContent = String(this.notificationCount);
@ -278,7 +278,7 @@ export class TopNavigation extends Component {
this.viewTabsContainer,
mode,
config.icon,
config.label
config.label,
);
}
}
@ -289,12 +289,12 @@ export class TopNavigation extends Component {
// Hide entire navigation if no modes available
if (modes.length === 0) {
this.containerEl.style.display = "none";
this.containerEl.hide();
return;
}
// Show navigation when modes are available
this.containerEl.style.display = "";
this.containerEl.show();
// If current mode is no longer available, switch to first available mode
if (!modes.includes(this.currentViewMode)) {
@ -305,10 +305,10 @@ export class TopNavigation extends Component {
// Update center section visibility (this should always be visible now since we handle empty modes above)
const centerSection = this.containerEl.querySelector(
".fluent-nav-center"
".fluent-nav-center",
) as HTMLElement;
if (centerSection) {
centerSection.style.display = "";
centerSection.show();
// Re-render the view tabs
if (!this.viewTabsContainer) {
this.viewTabsContainer = centerSection.createDiv({

View file

@ -1,4 +1,11 @@
import { App, ButtonComponent, Component, ItemView, Platform, WorkspaceLeaf, } from "obsidian";
import {
App,
ButtonComponent,
Component,
ItemView,
Platform,
WorkspaceLeaf,
} from "obsidian";
import TaskProgressBarPlugin from "@/index";
import { FluentSidebar } from "../components/FluentSidebar";
import { TaskDetailsComponent } from "@/components/features/task/view/details";
@ -6,7 +13,10 @@ import { Task } from "@/types/task";
import { t } from "@/translations/helper";
import { TG_LEFT_SIDEBAR_VIEW_TYPE } from "../../../../pages/LeftSidebarView";
import { QuickCaptureModal } from "@/components/features/quick-capture/modals/QuickCaptureModal";
import { ViewTaskFilterModal, ViewTaskFilterPopover, } from "@/components/features/task/filter";
import {
ViewTaskFilterModal,
ViewTaskFilterPopover,
} from "@/components/features/task/filter";
import { RootFilterState } from "@/components/features/task/filter/ViewTaskFilter";
/**
@ -47,7 +57,7 @@ export class FluentLayoutManager extends Component {
private onTaskEdit?: (task: Task) => void;
private onTaskUpdate?: (
originalTask: Task,
updatedTask: Task
updatedTask: Task,
) => Promise<void>;
private onFilterReset?: () => void;
private getLiveFilterState?: () => RootFilterState | null;
@ -60,7 +70,7 @@ export class FluentLayoutManager extends Component {
private rootContainerEl: HTMLElement,
private headerEl: HTMLElement,
private titleEl: HTMLElement,
private getTaskCount: () => number
private getTaskCount: () => number,
) {
super();
@ -109,7 +119,7 @@ export class FluentLayoutManager extends Component {
* Check if using workspace side leaves mode
*/
private useSideLeaves(): boolean {
return !!(this.plugin.settings.fluentView)?.useWorkspaceSideLeaves;
return !!this.plugin.settings.fluentView?.useWorkspaceSideLeaves;
}
/**
@ -119,7 +129,7 @@ export class FluentLayoutManager extends Component {
if (this.useSideLeaves()) {
containerEl.hide();
console.log(
"[FluentLayout] Using workspace side leaves: skip in-view sidebar"
"[FluentLayout] Using workspace side leaves: skip in-view sidebar",
);
return;
}
@ -146,7 +156,7 @@ export class FluentLayoutManager extends Component {
this.closeMobileDrawer();
}
},
initialCollapsedState
initialCollapsedState,
);
// Add sidebar as a child component for proper lifecycle management
@ -159,7 +169,7 @@ export class FluentLayoutManager extends Component {
initializeDetailsComponent(): void {
if (this.useSideLeaves()) {
console.log(
"[FluentLayout] Using workspace side leaves: skip in-view details panel"
"[FluentLayout] Using workspace side leaves: skip in-view details panel",
);
return;
}
@ -168,7 +178,7 @@ export class FluentLayoutManager extends Component {
this.detailsComponent = new TaskDetailsComponent(
this.rootContainerEl,
this.app,
this.plugin
this.plugin,
);
this.addChild(this.detailsComponent);
this.detailsComponent.load();
@ -180,7 +190,7 @@ export class FluentLayoutManager extends Component {
this.onTaskEdit?.(task);
this.detailsComponent.onTaskUpdate = async (
originalTask: Task,
updatedTask: Task
updatedTask: Task,
) => {
await this.onTaskUpdate?.(originalTask, updatedTask);
};
@ -196,11 +206,11 @@ export class FluentLayoutManager extends Component {
createSidebarToggle(): void {
const headerBtns = !Platform.isPhone
? (this.headerEl?.querySelector(
".view-header-nav-buttons"
) as HTMLElement | null)
".view-header-nav-buttons",
) as HTMLElement | null)
: (this.headerEl?.querySelector(
".view-header-left"
) as HTMLElement);
".view-header-left",
) as HTMLElement);
if (!headerBtns) {
console.warn("[FluentLayout] header buttons container not found");
@ -238,7 +248,7 @@ export class FluentLayoutManager extends Component {
interpolation: {
num: this.getTaskCount(),
},
})
}),
);
}
@ -286,7 +296,7 @@ export class FluentLayoutManager extends Component {
this.sidebar?.setCollapsed(this.isSidebarCollapsed);
this.rootContainerEl?.toggleClass(
"fluent-sidebar-collapsed",
this.isSidebarCollapsed
this.isSidebarCollapsed,
);
}
}
@ -298,7 +308,7 @@ export class FluentLayoutManager extends Component {
this.isMobileDrawerOpen = true;
this.rootContainerEl?.addClass("drawer-open");
if (this.drawerOverlay) {
this.drawerOverlay.style.display = "block";
this.drawerOverlay.show();
}
// Show the sidebar
this.sidebar?.setCollapsed(false);
@ -311,7 +321,7 @@ export class FluentLayoutManager extends Component {
this.isMobileDrawerOpen = false;
this.rootContainerEl?.removeClass("drawer-open");
if (this.drawerOverlay) {
this.drawerOverlay.style.display = "none";
this.drawerOverlay.hide();
}
// Hide the sidebar
this.sidebar?.setCollapsed(true);
@ -326,10 +336,17 @@ export class FluentLayoutManager extends Component {
this.drawerOverlay = layoutContainer.createDiv({
cls: "drawer-overlay",
});
this.drawerOverlay.style.display = "none";
this.drawerOverlay.addEventListener("click", () => {
this.closeMobileDrawer();
});
this.drawerOverlay.hide();
this.registerDomEvent(
this.drawerOverlay,
"click",
() => {
this.closeMobileDrawer();
},
{
once: true,
},
);
}
/**
@ -359,7 +376,7 @@ export class FluentLayoutManager extends Component {
this.detailsToggleBtn.toggleClass("is-active", visible);
this.detailsToggleBtn.setAttribute(
"aria-label",
visible ? t("Hide Details") : t("Show Details")
visible ? t("Hide Details") : t("Show Details"),
);
}
return;
@ -377,7 +394,7 @@ export class FluentLayoutManager extends Component {
this.detailsToggleBtn.toggleClass("is-active", visible);
this.detailsToggleBtn.setAttribute(
"aria-label",
visible ? t("Hide Details") : t("Show Details")
visible ? t("Hide Details") : t("Show Details"),
);
}
@ -393,7 +410,7 @@ export class FluentLayoutManager extends Component {
this.toggleDetailsVisibility(false);
document.removeEventListener(
"click",
overlayClickHandler
overlayClickHandler,
);
}
};
@ -407,7 +424,7 @@ export class FluentLayoutManager extends Component {
) {
document.removeEventListener(
"click",
this.mobileDetailsOverlayHandler
this.mobileDetailsOverlayHandler,
);
delete this.mobileDetailsOverlayHandler;
}
@ -501,14 +518,14 @@ export class FluentLayoutManager extends Component {
t("Details"),
() => {
this.toggleDetailsVisibility(!this.isDetailsVisible);
}
},
);
if (this.detailsToggleBtn) {
this.detailsToggleBtn.toggleClass("panel-toggle-btn", true);
this.detailsToggleBtn.toggleClass(
"is-active",
this.isDetailsVisible
this.isDetailsVisible,
);
}
@ -518,7 +535,7 @@ export class FluentLayoutManager extends Component {
this.app,
this.plugin,
{},
true
true,
);
modal.open();
});
@ -529,7 +546,7 @@ export class FluentLayoutManager extends Component {
const popover = new ViewTaskFilterPopover(
this.app,
undefined,
this.plugin
this.plugin,
);
// Set up filter state when opening
@ -538,18 +555,18 @@ export class FluentLayoutManager extends Component {
const liveFilterState = this.getLiveFilterState?.();
if (liveFilterState && popover.taskFilterComponent) {
popover.taskFilterComponent.loadFilterState(
liveFilterState
liveFilterState,
);
}
}, 100);
});
popover.showAtPosition({x: e.clientX, y: e.clientY});
popover.showAtPosition({ x: e.clientX, y: e.clientY });
} else {
const modal = new ViewTaskFilterModal(
this.app,
this.leaf.id,
this.plugin
this.plugin,
);
modal.open();
@ -559,7 +576,7 @@ export class FluentLayoutManager extends Component {
if (liveFilterState && modal.taskFilterComponent) {
setTimeout(() => {
modal.taskFilterComponent.loadFilterState(
liveFilterState
liveFilterState,
);
}, 100);
}
@ -576,7 +593,7 @@ export class FluentLayoutManager extends Component {
updateActionButtons(): void {
// Remove reset filter button if exists
const resetButton = this.headerEl.querySelector(
".view-action.task-filter-reset"
".view-action.task-filter-reset",
);
if (resetButton) {
resetButton.remove();
@ -604,7 +621,7 @@ export class FluentLayoutManager extends Component {
if (Platform.isPhone && (this as any).mobileDetailsOverlayHandler) {
document.removeEventListener(
"click",
(this as any).mobileDetailsOverlayHandler
(this as any).mobileDetailsOverlayHandler,
);
delete (this as any).mobileDetailsOverlayHandler;
}

View file

@ -458,14 +458,29 @@ function applyDateOperations(
operations: DateOperation[],
plugin: TaskProgressBarPlugin
): TransactionSpec {
// Early validation: ensure line number is within bounds
if (lineNumber < 1 || lineNumber > tr.newDoc.lines) {
console.warn(
`[AutoDateManager] Line number ${lineNumber} is out of bounds (doc has ${tr.newDoc.lines} lines)`
);
return tr;
}
// IMPORTANT: Use the NEW document state, not the old one
const line = tr.newDoc.line(lineNumber);
// Validate line boundaries
if (line.from > tr.newDoc.length || line.to > tr.newDoc.length) {
console.warn(
`[AutoDateManager] Line boundaries invalid: from=${line.from}, to=${line.to}, doc length=${tr.newDoc.length}`
);
return tr;
}
let lineText = line.text;
const changes = [];
console.log(
`[AutoDateManager] applyDateOperations - Working with line: "${lineText}"`
);
// Removed verbose logging
for (const operation of operations) {
if (operation.type === "add") {
@ -501,22 +516,21 @@ function applyDateOperations(
const absolutePosition = line.from + insertPosition;
console.log(
`[AutoDateManager] Inserting ${operation.dateType} date:`
);
console.log(` - Insert position (relative): ${insertPosition}`);
console.log(` - Line.from: ${line.from}`);
console.log(` - Absolute position: ${absolutePosition}`);
console.log(` - Date text: "${dateText}"`);
console.log(
` - Text at insert point: "${lineText.substring(
insertPosition
)}"`
);
// Ensure position is within document bounds
// Clamp to document length to prevent range errors
const safePosition = Math.min(absolutePosition, tr.newDoc.length);
const clampedPosition = Math.max(0, safePosition);
// Keep minimal logging for debugging
if (clampedPosition !== absolutePosition) {
console.log(
`[AutoDateManager] Position adjusted: ${absolutePosition} -> ${clampedPosition} (doc length: ${tr.newDoc.length})`
);
}
changes.push({
from: absolutePosition,
to: absolutePosition,
from: clampedPosition,
to: clampedPosition,
insert: dateText,
});
@ -574,9 +588,19 @@ function applyDateOperations(
const absoluteFrom = line.from + matchToRemove.start;
const absoluteTo = line.from + matchToRemove.end;
// Ensure positions are within document bounds
const safeFrom = Math.min(
Math.max(0, absoluteFrom),
tr.newDoc.length
);
const safeTo = Math.min(
Math.max(0, absoluteTo),
tr.newDoc.length
);
changes.push({
from: absoluteFrom,
to: absoluteTo,
from: safeFrom,
to: safeTo,
insert: "",
});
@ -589,8 +613,104 @@ function applyDateOperations(
}
if (changes.length > 0) {
// CRITICAL FIX: When multiple transaction filters run in sequence,
// positions must be handled very carefully.
// Our positions are calculated relative to tr.newDoc (after previous changes).
// However, when returning changes, CodeMirror expects them to be relative
// to the state AFTER tr.changes is applied, which IS tr.newDoc.
// So our positions should actually be correct as-is!
// The issue might be that we're seeing the document length at one point
// but by the time the changes are applied, something else has modified it.
// Let's validate and ensure our positions don't exceed bounds
const docLength = tr.newDoc.length;
const validatedChanges = changes.map((change, i) => {
// Ensure positions are within the document bounds of newDoc
let validFrom = Math.min(Math.max(0, change.from), docLength);
let validTo = Math.min(Math.max(0, change.to), docLength);
// Ensure from <= to
if (validFrom > validTo) {
validTo = validFrom;
}
// Log only if positions changed
if (change.from !== validFrom || change.to !== validTo) {
console.log(
`[AutoDateManager] Position adjusted: (${change.from},${change.to}) -> (${validFrom},${validTo})`
);
}
return {
from: validFrom,
to: validTo,
insert: change.insert,
};
});
// Check if there are existing changes
let existingChanges: Array<{
fromA: number;
toA: number;
fromB: number;
toB: number;
inserted: string;
}> = [];
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
existingChanges.push({
fromA,
toA,
fromB,
toB,
inserted: inserted.toString(),
});
});
// Only log if there are existing changes (indicating multiple filters)
if (existingChanges.length > 0) {
console.log(
`[AutoDateManager] Processing with ${existingChanges.length} existing changes from other filters`
);
}
// IMPORTANT: When we return changes combined with tr.changes,
// our change positions should be relative to the document state
// AFTER tr.changes is applied (which is tr.newDoc).
// This is exactly what we have, so we should be good.
// Let's also add an extra safety check
const finalChanges = validatedChanges.filter((change) => {
if (change.from > docLength || change.to > docLength) {
console.error(
`[AutoDateManager] ERROR: Change position exceeds document length! from=${change.from}, to=${change.to}, docLength=${docLength}`
);
return false;
}
return true;
});
// Rebuild a single combined change list relative to the ORIGINAL doc
// 1) Take the original transaction's changes as specs (relative to startState)
const baseChangeSpecs: { from: number; to: number; insert: string }[] =
[];
tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
baseChangeSpecs.push({
from: fromA,
to: toA,
insert: inserted.toString(),
});
});
// 2) Map our additional changes (currently in newDoc space) back to startState
const inverse = tr.changes.invert(tr.startState.doc);
const mappedFinalChanges = finalChanges.map((c) => ({
from: inverse.mapPos(c.from, -1),
to: inverse.mapPos(c.to, -1),
insert: c.insert,
}));
return {
changes: [tr.changes, ...changes],
changes: [...baseChangeSpecs, ...mappedFinalChanges],
selection: tr.selection,
annotations: [
taskStatusChangeAnnotation.of("autoDateManager.dateUpdate"),
@ -687,16 +807,16 @@ function findMetadataInsertPosition(
for (let i = 0; i < remainingText.length; i++) {
const char = remainingText[i];
const nextChar = remainingText[i + 1];
const twoChars = char + (nextChar || '');
const twoChars = char + (nextChar || "");
// Handle [[wiki links]] - they are part of content
if (twoChars === '[[') {
if (twoChars === "[[") {
inLink++;
contentEnd = position + i + 2;
i++; // Skip next char
continue;
}
if (twoChars === ']]' && inLink > 0) {
if (twoChars === "]]" && inLink > 0) {
inLink--;
contentEnd = position + i + 2;
i++; // Skip next char
@ -710,7 +830,7 @@ function findMetadataInsertPosition(
}
// Check for dataview metadata [field:: value]
if (char === '[' && !inDataview) {
if (char === "[" && !inDataview) {
const afterBracket = remainingText.slice(i + 1);
if (afterBracket.match(/^[a-zA-Z]+::/)) {
// This is dataview metadata, stop here
@ -719,8 +839,12 @@ function findMetadataInsertPosition(
}
// Check for tags (only if preceded by whitespace or at start)
if (char === '#') {
if (i === 0 || remainingText[i - 1] === ' ' || remainingText[i - 1] === '\t') {
if (char === "#") {
if (
i === 0 ||
remainingText[i - 1] === " " ||
remainingText[i - 1] === "\t"
) {
// Check if this is actually a tag (followed by word characters)
const afterHash = remainingText.slice(i + 1);
if (afterHash.match(/^[\w-]+/)) {
@ -731,7 +855,7 @@ function findMetadataInsertPosition(
}
// Check for date emojis (these are metadata markers)
const dateEmojis = ['📅', '🚀', '✅', '❌', '🛫', '▶️', '⏰', '🏁'];
const dateEmojis = ["📅", "🚀", "✅", "❌", "🛫", "▶️", "⏰", "🏁"];
if (dateEmojis.includes(char)) {
// Check if this is followed by a date pattern
const afterEmoji = remainingText.slice(i + 1);
@ -748,7 +872,7 @@ function findMetadataInsertPosition(
position = contentEnd;
// Trim trailing whitespace
while (position > taskMatch[0].length && lineText[position - 1] === ' ') {
while (position > taskMatch[0].length && lineText[position - 1] === " ") {
position--;
}
@ -827,9 +951,11 @@ function findMetadataInsertPosition(
}
}
console.log(
`[AutoDateManager] Final insert position for ${dateType}: ${position}`
);
// Final validation: ensure position doesn't exceed line length
position = Math.min(position, lineText.length);
position = Math.max(0, position);
// Removed verbose logging
return position;
}
@ -845,18 +971,25 @@ function findCompletedDateInsertPosition(
): number {
// Use centralized block reference detection
const blockRef = detectBlockReference(lineText);
let position: number;
if (blockRef) {
// Insert before the block reference ID
// Remove trailing space if exists
let position = blockRef.index;
position = blockRef.index;
if (position > 0 && lineText[position - 1] === " ") {
position--;
}
return position;
} else {
// If no block reference, insert at the very end
position = lineText.length;
}
// If no block reference, insert at the very end
return lineText.length;
// Validate position is within bounds
position = Math.min(position, lineText.length);
position = Math.max(0, position);
return position;
}
/**

View file

@ -45,7 +45,7 @@ class TaskStatusWidget extends WidgetType {
readonly from: number,
readonly to: number,
readonly currentState: TaskState,
readonly listPrefix: string
readonly listPrefix: string,
) {
super();
const config = this.getStatusConfig();
@ -69,7 +69,7 @@ class TaskStatusWidget extends WidgetType {
let nextState = this.currentState;
const remainingCycle = cycle.filter(
(state) => !excludeMarksFromCycle.includes(state)
(state) => !excludeMarksFromCycle.includes(state),
);
if (remainingCycle.length > 0) {
@ -101,7 +101,7 @@ class TaskStatusWidget extends WidgetType {
cls: isNumberedList ? "list-number" : "list-bullet",
text: this.bulletText,
});
}
},
);
}
@ -111,7 +111,7 @@ class TaskStatusWidget extends WidgetType {
"task-state",
this.isLivePreview ? "live-preview-mode" : "source-mode",
],
true
true,
);
// Add a specific class based on the mode
@ -253,7 +253,7 @@ class TaskStatusWidget extends WidgetType {
const { cycle, marks, excludeMarksFromCycle } = this.getStatusConfig();
const remainingCycle = cycle.filter(
(state) => !excludeMarksFromCycle.includes(state)
(state) => !excludeMarksFromCycle.includes(state),
);
if (remainingCycle.length === 0) {
@ -265,7 +265,7 @@ class TaskStatusWidget extends WidgetType {
}
// If no cycle is available, trigger the default editor:toggle-checklist-status command
this.app.commands.executeCommandById(
"editor:toggle-checklist-status"
"editor:toggle-checklist-status",
);
return;
}
@ -292,20 +292,6 @@ class TaskStatusWidget extends WidgetType {
// Replace text
const newText = currentText.replace(/\[(.)]/, `[${nextMark}]`);
// if (nextMark === "x" || nextMark === "X") {
// const line = this.view.state.doc.lineAt(this.from);
// const path =
// this.view.state.field(editorInfoField)?.file?.path || "";
// const task = parseTaskLine(
// path,
// line.text,
// line.number,
// this.plugin.settings.preferMetadataFormat
// );
// task &&
// this.app.workspace.trigger("task-genius:task-completed", task);
// }
this.view.dispatch({
changes: {
from: this.from,
@ -320,12 +306,12 @@ class TaskStatusWidget extends WidgetType {
export function taskStatusSwitcherExtension(
app: App,
plugin: TaskProgressBarPlugin
plugin: TaskProgressBarPlugin,
) {
class TaskStatusViewPluginValue implements PluginValue {
public readonly view: EditorView;
decorations: DecorationSet = Decoration.none;
private lastUpdate: number = 0;
private lastUpdate = 0;
private readonly updateThreshold: number = 50;
private readonly match = new MatchDecorator({
regexp: /^(\s*)((?:[-*+]|\d+[.)])\s)(\[(.)]\s)/g,
@ -334,7 +320,7 @@ export function taskStatusSwitcherExtension(
from: number,
to: number,
match: RegExpExecArray,
view: EditorView
view: EditorView,
) => {
if (!this.shouldRender(view, from, to)) {
return;
@ -351,7 +337,7 @@ export function taskStatusSwitcherExtension(
const excludeMarksFromCycle =
plugin.settings.excludeMarksFromCycle || [];
const remainingCycle = cycle.filter(
(state) => !excludeMarksFromCycle.includes(state)
(state) => !excludeMarksFromCycle.includes(state),
);
if (
@ -385,9 +371,9 @@ export function taskStatusSwitcherExtension(
checkboxStart,
checkboxEnd,
currentState,
bulletText
bulletText,
),
})
}),
);
} else {
// In Live Preview mode, replace the whole bullet point + checkbox
@ -408,9 +394,9 @@ export function taskStatusSwitcherExtension(
bulletWithSpace.length +
checkbox.length,
currentState,
bulletText
bulletText,
),
})
}),
);
}
},
@ -449,7 +435,7 @@ export function taskStatusSwitcherExtension(
} else {
this.decorations = this.match.updateDeco(
update,
this.decorations
this.decorations,
);
}
}
@ -461,10 +447,10 @@ export function taskStatusSwitcherExtension(
shouldRender(
view: EditorView,
decorationFrom: number,
decorationTo: number
decorationTo: number,
) {
const syntaxNode = syntaxTree(view.state).resolveInner(
decorationFrom + 1
decorationFrom + 1,
);
const nodeProps = syntaxNode.type.prop(tokenClassNodeProp);
@ -498,7 +484,7 @@ export function taskStatusSwitcherExtension(
filter: (
rangeFrom: number,
rangeTo: number,
deco: Decoration
deco: Decoration,
) => {
const widget = deco.spec?.widget;
if ((widget as any).error) {
@ -521,6 +507,6 @@ export function taskStatusSwitcherExtension(
return ViewPlugin.fromClass(
TaskStatusViewPluginValue,
TaskStatusViewPluginSpec
TaskStatusViewPluginSpec,
);
}

View file

@ -65,7 +65,7 @@ function getIndentation(text: string): string {
function removeTrailingIndentation(
indentation: string,
app: App,
levels: number = 1,
levels: number = 1
): string {
const indentUnit = buildIndentString(app);
const removal = indentUnit.repeat(levels);
@ -73,7 +73,7 @@ function removeTrailingIndentation(
if (indentation.endsWith(removal)) {
return indentation.slice(
0,
Math.max(0, indentation.length - removal.length),
Math.max(0, indentation.length - removal.length)
);
}
@ -81,7 +81,7 @@ function removeTrailingIndentation(
const fallbackLength = indentUnit.length * levels;
return indentation.slice(
0,
Math.max(0, indentation.length - fallbackLength),
Math.max(0, indentation.length - fallbackLength)
);
}
@ -118,7 +118,7 @@ interface TextRange {
*/
function calculateRangeForTransform(
state: EditorState,
pos: number,
pos: number
): TextRange | null {
const line = state.doc.lineAt(pos);
const foldRange = foldable(state, line.from, line.to);
@ -226,7 +226,7 @@ export function findParentWorkflow(doc: Text, lineNum: number): string | null {
export function handleWorkflowTransaction(
tr: Transaction,
app: App,
plugin: TaskProgressBarPlugin,
plugin: TaskProgressBarPlugin
): TransactionSpec {
// Only process if workflow feature is enabled
if (!plugin.settings.workflow.enableWorkflow) {
@ -239,12 +239,14 @@ export function handleWorkflowTransaction(
}
// Skip if this transaction already has a workflow or task status annotation
const tsAnn = tr.annotation(taskStatusChangeAnnotation) as
| string
| undefined;
if (
tr.annotation(workflowChangeAnnotation) ||
tr.annotation(priorityChangeAnnotation) ||
(tr.annotation(taskStatusChangeAnnotation) as string)?.startsWith(
"workflowChange",
)
tsAnn?.startsWith("workflowChange") ||
tsAnn?.startsWith("autoDateManager")
) {
return tr;
}
@ -271,7 +273,7 @@ export function handleWorkflowTransaction(
const isCompletionChange = (text: string) =>
completedStatuses.includes(text) ||
completedStatuses.some(
(status) => text === `- [${status}]` || text === `[${status}]`,
(status) => text === `- [${status}]` || text === `[${status}]`
);
if (!changes.some((c) => isCompletionChange(c.text))) {
@ -297,7 +299,7 @@ export function handleWorkflowTransaction(
lineText,
tr.newDoc,
line.number,
plugin,
plugin
);
if (resolvedInfo) {
@ -309,6 +311,21 @@ export function handleWorkflowTransaction(
}
}
// Check if there are existing changes from other transaction filters
// (e.g., date manager) and adjust our calculations accordingly
let existingChangeAdjustment = 0;
let hasExistingChanges = false;
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
// Calculate how much the document has grown from existing changes
const insertedLength = inserted.length;
const deletedLength = toA - fromA;
existingChangeAdjustment += insertedLength - deletedLength;
hasExistingChanges = true;
});
// Logging moved to where it's used
const newChanges: { from: number; to: number; insert: string }[] = [];
// Process each workflow update
if (workflowUpdates.length > 0) {
@ -334,22 +351,36 @@ export function handleWorkflowTransaction(
line.from,
line.number,
workflowType,
plugin,
plugin
);
newChanges.push(...timeChanges);
// Adjust time change positions if there are existing changes
const adjustedTimeChanges = timeChanges;
for (const change of adjustedTimeChanges) {
newChanges.push(change);
}
let stageRemovalChange: {
from: number;
to: number;
insert: string;
} | null = null;
// Remove the [stage::] marker from the current line
if (plugin.settings.workflow.autoRemoveLastStageMarker) {
const stageMarker = safelyFindStageMarker(line.text);
if (stageMarker) {
newChanges.push({
from: line.from + stageMarker.index,
to:
line.from +
stageMarker.index +
stageMarker[0].length,
let fromPos = line.from + stageMarker.index;
let toPos =
line.from + stageMarker.index + stageMarker[0].length;
stageRemovalChange = {
from: fromPos,
to: toPos,
insert: "",
});
};
newChanges.push(stageRemovalChange);
}
}
@ -376,9 +407,18 @@ export function handleWorkflowTransaction(
if (rootTaskMatch) {
const rootTaskStatus = rootTaskMatch[3];
if (!completedStatuses.includes(rootTaskStatus)) {
const rootTaskStart =
let rootTaskStart =
checkLine.from +
rootTaskMatch[0].indexOf("[");
// Adjust positions if there are existing changes
if (hasExistingChanges) {
rootTaskStart = Math.max(
0,
rootTaskStart - existingChangeAdjustment
);
}
newChanges.push({
from: rootTaskStart + 1,
to: rootTaskStart + 2,
@ -395,16 +435,32 @@ export function handleWorkflowTransaction(
const { nextStageId, nextSubStageId } = determineNextStage(
currentStage,
workflow,
currentSubStage,
currentSubStage
);
// Guard: If completing a parent cycle stage (no currentSubStage) and there's no explicit
// next main stage configured (nextStageId remains current), skip auto-creating the first
// substage to avoid unintended child looping when advancing parent via menu.
if (
currentStage.type === "cycle" &&
!currentSubStage &&
nextStageId === currentStage.id
) {
// Optional debug
console.debug(
"[WorkflowHandler] Skip auto-substage creation for parent cycle stage completion",
{ line: line.number, stage: currentStage.id }
);
continue;
}
const nextStage = workflow.stages.find((s) => s.id === nextStageId);
if (!nextStage) continue;
let nextSubStage: WorkflowSubStage | undefined;
if (nextSubStageId && nextStage.subStages) {
nextSubStage = nextStage.subStages.find(
(ss) => ss.id === nextSubStageId,
(ss) => ss.id === nextSubStageId
);
}
@ -419,15 +475,34 @@ export function handleWorkflowTransaction(
newTaskIndentation,
plugin,
true,
nextSubStage,
nextSubStage
);
const insertionPoint = determineTaskInsertionPoint(
line,
tr.newDoc,
indentation,
indentation
);
const insertionAdjustment = calculateInsertionAdjustment(
insertionPoint,
line.from,
[
...timeChanges,
...(stageRemovalChange ? [stageRemovalChange] : []),
]
);
// Calculate the adjusted insertion point
let adjustedInsertionPoint = insertionPoint + insertionAdjustment;
// Ensure the insertion point is within the new document bounds (after original changes)
adjustedInsertionPoint = Math.min(
adjustedInsertionPoint,
tr.newDoc.length
);
adjustedInsertionPoint = Math.max(0, adjustedInsertionPoint);
if (
!(
tr.annotation(taskStatusChangeAnnotation) ===
@ -435,8 +510,8 @@ export function handleWorkflowTransaction(
)
) {
newChanges.push({
from: insertionPoint,
to: insertionPoint,
from: adjustedInsertionPoint,
to: adjustedInsertionPoint,
insert: `\n${completeTaskText}`,
});
}
@ -444,8 +519,33 @@ export function handleWorkflowTransaction(
}
if (newChanges.length > 0) {
// Log only if processing with existing changes
if (hasExistingChanges) {
console.log(
`[WorkflowHandler] Applied ${newChanges.length} changes (adjusted for existing changes)`
);
}
// Rebuild a single combined change list relative to the ORIGINAL doc
// 1) Take the original transaction's changes as specs (relative to startState)
const baseChangeSpecs: { from: number; to: number; insert: string }[] =
[];
tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
baseChangeSpecs.push({
from: fromA,
to: toA,
insert: inserted.toString(),
});
});
// 2) Map our additional changes (currently in newDoc space) back to startState
const inverse = tr.changes.invert(tr.startState.doc);
const mappedNewChanges = newChanges.map((c) => ({
from: inverse.mapPos(c.from, -1),
to: inverse.mapPos(c.to, -1),
insert: c.insert,
}));
return {
changes: [tr.changes, ...newChanges],
changes: [...baseChangeSpecs, ...mappedNewChanges],
selection: tr.selection,
annotations: workflowChangeAnnotation.of("workflowChange"),
};
@ -470,7 +570,7 @@ export function processTimestampAndCalculateTime(
lineFrom: number,
lineNumber: number,
workflowType: string,
plugin: TaskProgressBarPlugin,
plugin: TaskProgressBarPlugin
): { from: number; to: number; insert: string }[] {
const changes: { from: number; to: number; insert: string }[] = [];
@ -489,7 +589,7 @@ export function processTimestampAndCalculateTime(
const startTime = moment(
timestampText.replace("🛫 ", ""),
timestampFormat,
true,
true
);
if (!startTime.isValid()) {
@ -502,7 +602,7 @@ export function processTimestampAndCalculateTime(
lineText,
lineNumber,
doc,
plugin,
plugin
);
// Remove timestamp if enabled
@ -518,22 +618,28 @@ export function processTimestampAndCalculateTime(
// Add spent time if enabled
if (plugin.settings.workflow.calculateSpentTime) {
const spentTime = moment
.utc(duration.asMilliseconds())
.format(plugin.settings.workflow.spentTimeFormat);
// Determine insertion position (before any stage marker)
const stageMarkerIndex = lineText.indexOf("[stage::");
const insertPosition =
lineFrom +
(stageMarkerIndex !== -1 ? stageMarkerIndex : lineText.length);
if (!isFinalStage || !plugin.settings.workflow.calculateFullSpentTime) {
changes.push({
from: insertPosition,
to: insertPosition,
insert: ` (⏱️ ${spentTime})`,
});
// Guard: avoid duplicating local spent time if it already exists on the line
if (!TIME_SPENT_REGEX.test(lineText)) {
const spentTime = moment
.utc(duration.asMilliseconds())
.format(plugin.settings.workflow.spentTimeFormat);
if (
!isFinalStage ||
!plugin.settings.workflow.calculateFullSpentTime
) {
changes.push({
from: insertPosition,
to: insertPosition,
insert: ` (⏱️ ${spentTime})`,
});
}
}
// Calculate and add total time for final stage if enabled
@ -558,7 +664,7 @@ export function processTimestampAndCalculateTime(
const taskLine = doc.line(j);
const indentLevel = getIndentation(
taskLine.text,
taskLine.text
).length;
if (indentLevel > currentIndentLevel) {
@ -634,7 +740,7 @@ export function processTimestampAndCalculateTime(
export function updateWorkflowContextMenu(
menu: any,
editor: Editor,
plugin: TaskProgressBarPlugin,
plugin: TaskProgressBarPlugin
) {
if (!plugin.settings.workflow.enableWorkflow) {
return;
@ -701,7 +807,7 @@ export function updateWorkflowContextMenu(
false,
editor,
null as any,
plugin,
plugin
);
});
});
@ -715,7 +821,7 @@ export function updateWorkflowContextMenu(
false,
editor,
null as any,
plugin,
plugin
);
});
});
@ -729,7 +835,7 @@ export function updateWorkflowContextMenu(
false,
editor,
null as any,
plugin,
plugin
);
});
});
@ -743,7 +849,7 @@ export function updateWorkflowContextMenu(
line,
editor.cm.state.doc,
cursor.line + 1,
plugin,
plugin
);
if (!resolvedInfo) {
@ -771,7 +877,7 @@ export function updateWorkflowContextMenu(
const firstStage = workflow.stages[0];
submenu.addItem((nextItem: any) => {
nextItem.setTitle(
`${t("Move to stage")} ${firstStage.name}`,
`${t("Move to stage")} ${firstStage.name}`
);
nextItem.onClick(() => {
const changes = createWorkflowStageTransition(
@ -782,13 +888,15 @@ export function updateWorkflowContextMenu(
firstStage,
true,
undefined,
undefined,
undefined
);
editor.cm.dispatch({
changes,
annotations:
annotations: [
taskStatusChangeAnnotation.of("workflowChange"),
workflowChangeAnnotation.of("workflowChange"),
],
});
});
});
@ -796,7 +904,7 @@ export function updateWorkflowContextMenu(
} else if (currentStage.canProceedTo) {
currentStage.canProceedTo.forEach((nextStageId) => {
const nextStage = workflow.stages.find(
(s) => s.id === nextStageId,
(s) => s.id === nextStageId
);
if (nextStage) {
@ -806,14 +914,14 @@ export function updateWorkflowContextMenu(
line,
cursor.line,
editor.cm.state.doc,
plugin,
plugin
);
// If last stage, show "Complete stage" instead of "Move to"
nextItem.setTitle(
isLastStage
? `${t("Complete stage")}: ${nextStage.name}`
: `${t("Move to stage")} ${nextStage.name}`,
: `${t("Move to stage")} ${nextStage.name}`
);
nextItem.onClick(() => {
const changes = createWorkflowStageTransition(
@ -824,15 +932,20 @@ export function updateWorkflowContextMenu(
nextStage,
false,
undefined,
currentSubStage,
currentSubStage
);
editor.cm.dispatch({
changes,
annotations: taskStatusChangeAnnotation.of(
isLastStage
? "workflowChange.completeStage"
: "workflowChange.moveToStage",
),
annotations: [
taskStatusChangeAnnotation.of(
isLastStage
? "workflowChange.completeStage"
: "workflowChange.moveToStage"
),
workflowChangeAnnotation.of(
"workflowChange"
),
],
});
});
});
@ -850,13 +963,15 @@ export function updateWorkflowContextMenu(
currentStage,
false,
undefined,
currentSubStage,
currentSubStage
);
editor.cm.dispatch({
changes,
annotations:
annotations: [
taskStatusChangeAnnotation.of("workflowChange"),
workflowChangeAnnotation.of("workflowChange"),
],
});
});
});
@ -865,13 +980,13 @@ export function updateWorkflowContextMenu(
const { nextStageId } = determineNextStage(
currentStage,
workflow,
currentSubStage,
currentSubStage
);
// Only add menu option if there's a valid next stage that's different from current
if (nextStageId && nextStageId !== currentStage.id) {
const nextStage = workflow.stages.find(
(s) => s.id === nextStageId,
(s) => s.id === nextStageId
);
if (nextStage) {
submenu.addItem((nextItem: any) => {
@ -885,15 +1000,19 @@ export function updateWorkflowContextMenu(
nextStage,
false,
undefined,
undefined,
undefined
);
editor.cm.dispatch({
changes,
annotations:
annotations: [
taskStatusChangeAnnotation.of(
"workflowChange",
"workflowChange"
),
workflowChangeAnnotation.of(
"workflowChange"
),
],
});
});
});
@ -918,12 +1037,14 @@ export function updateWorkflowContextMenu(
firstStage,
false,
undefined,
undefined,
undefined
);
editor.cm.dispatch({
changes,
annotations:
annotations: [
taskStatusChangeAnnotation.of("workflowChange"),
workflowChangeAnnotation.of("workflowChange"),
],
});
}
} else if (currentStage.id === "_root_task_") {
@ -937,12 +1058,14 @@ export function updateWorkflowContextMenu(
firstStage,
false,
undefined,
undefined,
undefined
);
editor.cm.dispatch({
changes,
annotations:
annotations: [
taskStatusChangeAnnotation.of("workflowChange"),
workflowChangeAnnotation.of("workflowChange"),
],
});
}
} else {
@ -954,12 +1077,14 @@ export function updateWorkflowContextMenu(
currentStage,
false,
currentSubStage,
undefined,
undefined
);
editor.cm.dispatch({
changes,
annotations:
annotations: [
taskStatusChangeAnnotation.of("workflowChange"),
workflowChangeAnnotation.of("workflowChange"),
],
});
}
});
@ -980,7 +1105,7 @@ export function isLastWorkflowStageOrNotWorkflow(
lineText: string,
lineNumber: number,
doc: Text,
plugin: TaskProgressBarPlugin,
plugin: TaskProgressBarPlugin
): boolean {
const workflowInfo = extractWorkflowInfo(lineText);
if (!workflowInfo) {
@ -1002,7 +1127,7 @@ export function isLastWorkflowStageOrNotWorkflow(
}
const workflow = plugin.settings.workflow.definitions.find(
(wf: WorkflowDefinition) => wf.id === workflowType,
(wf: WorkflowDefinition) => wf.id === workflowType
);
if (!workflow) {
@ -1028,7 +1153,7 @@ export function isLastWorkflowStageOrNotWorkflow(
currentSubStageId
) {
const currentSubStage = currentStage.subStages.find(
(ss) => ss.id === currentSubStageId,
(ss) => ss.id === currentSubStageId
);
if (!currentSubStage) {
return true;
@ -1047,7 +1172,7 @@ export function isLastWorkflowStageOrNotWorkflow(
!parentStageHasLinearNext
) {
const currentIndex = workflow.stages.findIndex(
(s) => s.id === currentStage.id,
(s) => s.id === currentStage.id
);
if (currentIndex === workflow.stages.length - 1) {
return true;
@ -1064,7 +1189,7 @@ export function isLastWorkflowStageOrNotWorkflow(
}
const currentIndex = workflow.stages.findIndex(
(s) => s.id === currentStage.id,
(s) => s.id === currentStage.id
);
if (currentIndex < 0) {
return true;
@ -1086,7 +1211,7 @@ export function isLastWorkflowStageOrNotWorkflow(
export function determineNextStage(
currentStage: WorkflowStage,
workflow: WorkflowDefinition,
currentSubStage?: WorkflowSubStage,
currentSubStage?: WorkflowSubStage
): { nextStageId: string; nextSubStageId?: string } {
let nextStageId = currentStage.id;
let nextSubStageId: string | undefined;
@ -1135,7 +1260,7 @@ export function determineNextStage(
nextStageId = currentStage.canProceedTo[0];
} else {
const currentIndex = workflow.stages.findIndex(
(stage) => stage.id === currentStage.id,
(stage) => stage.id === currentStage.id
);
if (
currentIndex >= 0 &&
@ -1166,7 +1291,7 @@ export function createWorkflowStageTransition(
nextStage: WorkflowStage,
isRootTask: boolean,
nextSubStage?: WorkflowSubStage,
currentSubStage?: WorkflowSubStage,
currentSubStage?: WorkflowSubStage
) {
const doc = editor.cm.state.doc;
const app = plugin.app;
@ -1185,7 +1310,7 @@ export function createWorkflowStageTransition(
line,
lineNumber,
doc,
plugin,
plugin
);
const taskMatch = line.match(TASK_REGEX);
@ -1214,7 +1339,7 @@ export function createWorkflowStageTransition(
lineStart.from,
lineNumber,
workflowType,
plugin,
plugin
);
changes.push(...timeChanges);
@ -1239,13 +1364,15 @@ export function createWorkflowStageTransition(
indentation,
plugin,
true,
nextSubStage,
nextSubStage
);
// Add the new task after the current line
// Ensure the insertion point is within document bounds
const insertPoint = Math.min(lineStart.to, doc.length);
changes.push({
from: lineStart.to,
to: lineStart.to,
from: insertPoint,
to: insertPoint,
insert: `\n${newTaskText}`,
});
}
@ -1277,7 +1404,7 @@ export function resolveWorkflowInfo(
lineText: string,
doc: Text,
lineNumber: number,
plugin: TaskProgressBarPlugin,
plugin: TaskProgressBarPlugin
): {
workflowType: string;
currentStage: WorkflowStage;
@ -1305,7 +1432,7 @@ export function resolveWorkflowInfo(
}
const workflow = plugin.settings.workflow.definitions.find(
(wf: WorkflowDefinition) => wf.id === workflowType,
(wf: WorkflowDefinition) => wf.id === workflowType
);
if (!workflow) {
return null;
@ -1337,7 +1464,7 @@ export function resolveWorkflowInfo(
let currentSubStage: WorkflowSubStage | undefined;
if (subStageId && currentStage.subStages) {
currentSubStage = currentStage.subStages.find(
(ss) => ss.id === subStageId,
(ss) => ss.id === subStageId
);
}
@ -1364,14 +1491,14 @@ export function generateWorkflowTaskText(
indentation: string,
plugin: TaskProgressBarPlugin,
addSubtasks: boolean = true,
nextSubStage?: WorkflowSubStage,
nextSubStage?: WorkflowSubStage
): string {
// Generate timestamp if configured
const timestamp = plugin.settings.workflow.autoAddTimestamp
? ` 🛫 ${moment().format(
plugin.settings.workflow.timestampFormat ||
"YYYY-MM-DD HH:mm:ss",
)}`
"YYYY-MM-DD HH:mm:ss"
)}`
: "";
const defaultIndentation = buildIndentString(plugin.app);
@ -1406,11 +1533,16 @@ export function generateWorkflowTaskText(
export function determineTaskInsertionPoint(
line: { number: number; to: number; text: string },
doc: Text,
indentation: string,
indentation: string
): number {
// Default insertion point is after the current line
let insertionPoint = line.to;
// Validate that the initial insertion point is within bounds
if (insertionPoint > doc.length) {
insertionPoint = doc.length;
}
// Check if there are child tasks by looking for lines with greater indentation
const lineIndent = indentation.length;
let lastChildLine = line.number;
@ -1418,11 +1550,14 @@ export function determineTaskInsertionPoint(
// Look at the next 20 lines to find potential child tasks
// This is a reasonable limit for most task hierarchies
for (
let i = line.number + 1;
i <= Math.min(line.number + 20, doc.lines);
i++
) {
const maxSearchLine = Math.min(line.number + 20, doc.lines);
for (let i = line.number + 1; i <= maxSearchLine; i++) {
// Ensure the line number is valid
if (i > doc.lines) {
break;
}
const checkLine = doc.line(i);
const checkIndent = getIndentation(checkLine.text).length;
@ -1435,9 +1570,51 @@ export function determineTaskInsertionPoint(
}
// If we found child tasks, insert after the last child
if (foundChildren) {
insertionPoint = doc.line(lastChildLine).to;
if (foundChildren && lastChildLine <= doc.lines) {
const lastChild = doc.line(lastChildLine);
insertionPoint = lastChild.to;
// Ensure the insertion point doesn't exceed document bounds
if (insertionPoint > doc.length) {
insertionPoint = doc.length;
}
}
return insertionPoint;
}
function calculateInsertionAdjustment(
baseInsertionPoint: number,
lineStart: number,
changes: { from: number; to: number; insert: string }[]
): number {
let adjustment = 0;
// Sort changes by position (descending) to process from end to beginning
const sortedChanges = [...changes].sort((a, b) => b.from - a.from);
for (const change of sortedChanges) {
const changeStart = change.from;
const changeEnd = change.to ?? change.from;
// Only consider changes that occur before the insertion point
// This accounts for ALL changes that affect the insertion position
if (changeStart >= baseInsertionPoint) {
continue;
}
const insertedLength = change.insert ? change.insert.length : 0;
const removedLength = changeEnd - changeStart;
// If the change ends before the insertion point, adjust by the full amount
if (changeEnd <= baseInsertionPoint) {
adjustment += insertedLength - removedLength;
} else {
// If the change partially overlaps, only adjust by the part before insertion point
const effectiveRemovedLength = baseInsertionPoint - changeStart;
adjustment += insertedLength - effectiveRemovedLength;
}
}
return adjustment;
}

View file

@ -4,7 +4,7 @@
overflow-y: hidden;
}
.onboarding-view:has(.component-showcase) .onboarding-header {
.onboarding-view:has(.component-showcase, .intro-line) .onboarding-header {
padding: calc(var(--onboarding-spacing) * 2) var(--onboarding-spacing)
var(--size-4-2) var(--onboarding-spacing);
}

View file

@ -222,7 +222,6 @@
padding-top: var(--size-4-4);
}
.fluent-view-tabs.tg-index-task-source-switcher {
display: flex;
align-items: stretch;
@ -274,3 +273,11 @@
display: inline-flex;
align-items: center;
}
.file-name-templates-container input {
flex: 1;
}
.file-name-templates-container .setting-item-info {
display: none;
}

View file

@ -55,7 +55,6 @@
.workspace-settings-selector-button .workspace-icon svg {
width: 16px;
height: 16px;
fill: var(--text-muted);
}
/* Workspace name */
@ -128,3 +127,9 @@
width: 100%;
}
}
.task-genius-settings .tg-workspace-selector-container .workspace-icon {
width: unset;
height: unset;
color: inherit;
}

View file

@ -168,7 +168,7 @@ export const getCachedChangelog = (
export const getLatestCachedChangelog = (
isBeta: boolean,
app?: App,
app: App,
): ChangelogCacheEntry | null => {
const cache = loadCache(app);
const channel = getChannelKey(isBeta);
@ -179,7 +179,7 @@ export const cacheChangelog = (
version: string,
isBeta: boolean,
data: Pick<ChangelogCacheEntry, "markdown" | "sourceUrl">,
app?: App,
app: App,
): void => {
const cache = loadCache(app);
const channel = getChannelKey(isBeta);

File diff suppressed because one or more lines are too long