refactor: vanillajs to react

This commit is contained in:
Aleix Soler 2025-07-15 17:30:17 +02:00
parent db3e6cea1a
commit cb4fda7bf1
131 changed files with 7522 additions and 8854 deletions

View file

@ -0,0 +1,46 @@
import { GroupedStatuses } from "@/types/noteStatus";
import React, { FC } from "react";
type Props = {
statuses: GroupedStatuses;
onMouseEnter: (statuses: GroupedStatuses) => void;
onMouseLeave: (statuses: GroupedStatuses) => void;
};
export const FileExplorerIcon: FC<Props> = ({
statuses,
onMouseLeave,
onMouseEnter,
}) => {
const statusEntries = Object.entries(statuses);
const totalStatuses = statusEntries.reduce(
(acc, [, list]) => acc + list.length,
0,
);
if (totalStatuses === 0) return null;
const primaryStatus = statusEntries[0]?.[1]?.[0];
if (!primaryStatus) return null;
return (
<div className="status-wrapper">
<div
className="status-minimal"
onMouseEnter={() => onMouseEnter(statuses)}
onMouseLeave={() => onMouseLeave(statuses)}
style={
{
"--primary-color":
primaryStatus.color || "var(--text-accent)",
} as React.CSSProperties
}
>
<span className="status-icon">{primaryStatus.icon}</span>
{totalStatuses > 1 && (
<span className="status-count">{totalStatuses}</span>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,259 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { FilterSection } from "./components/FilterSection";
import { TagSection } from "./components/TagSection";
export type FileItem = {
id: string;
name: string;
path: string;
};
export type StatusItem = {
name: string;
color: string;
icon?: string;
};
export type FilesByStatus = {
[statusName: string]: FileItem[];
};
export type GroupedByStatus = {
[frontmatterTag: string]: FilesByStatus;
};
export type GrouppedStatusViewProps = {
getAllFiles: () => FileItem[];
processFiles: (files: FileItem[]) => GroupedByStatus;
onFileClick: (file: FileItem) => void;
subscribeToEvents: (onDataChange: () => void) => () => void;
getAvailableStatuses: () => StatusItem[];
};
const ITEMS_PER_LOAD = 20;
export const GrouppedStatusView = ({
getAllFiles,
processFiles,
onFileClick,
subscribeToEvents,
getAvailableStatuses,
}: GrouppedStatusViewProps) => {
const [groupedData, setGroupedData] = useState<GroupedByStatus>({});
const [searchFilter, setSearchFilter] = useState("");
const [noteNameFilter, setNoteNameFilter] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
new Set(),
);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const [loadedItems, setLoadedItems] = useState<Record<string, number>>({});
const [isLoading, setIsLoading] = useState(true);
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const files = getAllFiles();
const processed = processFiles(files);
setGroupedData(processed);
} finally {
setIsLoading(false);
}
}, [getAllFiles, processFiles]);
useEffect(() => {
loadData();
const handleFileChange = () => {
loadData();
};
const unsubscribe = subscribeToEvents(handleFileChange);
return unsubscribe;
}, [loadData, subscribeToEvents]);
const filteredData = useMemo(() => {
if (!searchFilter.trim() && !noteNameFilter.trim()) return groupedData;
const filtered: GroupedByStatus = {};
const searchLower = searchFilter.toLowerCase();
const noteNameLower = noteNameFilter.toLowerCase();
Object.entries(groupedData).forEach(([tag, statusGroups]) => {
filtered[tag] = {};
Object.entries(statusGroups).forEach(([statusName, files]) => {
let matchingFiles = files;
// Filter by status/tag if searchFilter is provided
if (searchFilter.trim()) {
matchingFiles = matchingFiles.filter(
(file) =>
statusName.toLowerCase().includes(searchLower) ||
tag.toLowerCase().includes(searchLower),
);
}
// Filter by note name if noteNameFilter is provided
if (noteNameFilter.trim()) {
matchingFiles = matchingFiles.filter(
(file) =>
file.name.toLowerCase().includes(noteNameLower) ||
file.path.toLowerCase().includes(noteNameLower),
);
}
if (matchingFiles.length > 0) {
filtered[tag][statusName] = matchingFiles;
}
});
});
return filtered;
}, [groupedData, searchFilter, noteNameFilter]);
// Auto-expand groups when there's only one group
useEffect(() => {
const dataToCheck = filteredData;
const groupKeys = Object.keys(dataToCheck);
if (groupKeys.length === 1) {
const singleGroupKey = `tag-${groupKeys[0]}`;
setExpandedGroups((prev) => {
const newSet = new Set(prev);
newSet.add(singleGroupKey);
return newSet;
});
// Also expand all status groups within the single tag group
const statusGroups = dataToCheck[groupKeys[0]];
const statusKeys = Object.keys(statusGroups).map(
(statusName) => `${groupKeys[0]}-${statusName}`,
);
setExpandedFiles((prev) => {
const newSet = new Set(prev);
statusKeys.forEach((key) => newSet.add(key));
return newSet;
});
}
}, [filteredData]);
const toggleGroup = useCallback((groupKey: string) => {
setExpandedGroups((prev) => {
const newSet = new Set(prev);
if (newSet.has(groupKey)) {
newSet.delete(groupKey);
} else {
newSet.add(groupKey);
}
return newSet;
});
}, []);
const toggleFiles = useCallback((groupKey: string) => {
setExpandedFiles((prev) => {
const newSet = new Set(prev);
if (newSet.has(groupKey)) {
newSet.delete(groupKey);
} else {
newSet.add(groupKey);
}
return newSet;
});
}, []);
const getLoadedCount = useCallback(
(groupKey: string) => {
return loadedItems[groupKey] || ITEMS_PER_LOAD;
},
[loadedItems],
);
const loadMoreItems = useCallback((groupKey: string) => {
setLoadedItems((prev) => ({
...prev,
[groupKey]: (prev[groupKey] || ITEMS_PER_LOAD) + ITEMS_PER_LOAD,
}));
}, []);
const handleScroll = useCallback(
(
e: React.UIEvent<HTMLDivElement>,
groupKey: string,
totalItems: number,
) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
const loadedCount = getLoadedCount(groupKey);
if (
scrollHeight - scrollTop <= clientHeight + 100 &&
loadedCount < totalItems
) {
loadMoreItems(groupKey);
}
},
[getLoadedCount, loadMoreItems],
);
const handleFileClickCallback = useCallback(
(file: FileItem) => {
onFileClick(file);
},
[onFileClick],
);
const availableStatuses = getAvailableStatuses();
const statusMap = new Map(availableStatuses.map((s) => [s.name, s]));
if (isLoading) {
return (
<div className="groupped-status-view">
<div className="groupped-status-loading">
Loading statuses...
</div>
</div>
);
}
return (
<div className="groupped-status-view">
<FilterSection
searchFilter={searchFilter}
noteNameFilter={noteNameFilter}
onSearchFilterChange={setSearchFilter}
onNoteNameFilterChange={setNoteNameFilter}
/>
<div className="groupped-status-content">
{Object.entries(filteredData).map(([tag, statusGroups]) => {
const tagKey = `tag-${tag}`;
const isTagExpanded = expandedGroups.has(tagKey);
return (
<TagSection
key={tag}
tag={tag}
statusGroups={statusGroups}
isExpanded={isTagExpanded}
statusMap={statusMap}
expandedFiles={expandedFiles}
getLoadedCount={getLoadedCount}
onToggle={() => toggleGroup(tagKey)}
onToggleFiles={toggleFiles}
onFileClick={handleFileClickCallback}
onScroll={handleScroll}
onLoadMore={loadMoreItems}
/>
);
})}
{Object.keys(filteredData).length === 0 && (
<div className="groupped-status-empty">
{searchFilter
? "No files match your search."
: "No statuses found."}
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,78 @@
import React, { useCallback } from "react";
import { FileItem } from "../GrouppedStatusView";
interface FileListProps {
files: FileItem[];
groupKey: string;
loadedCount: number;
onFileClick: (file: FileItem) => void;
onScroll: (
e: React.UIEvent<HTMLDivElement>,
groupKey: string,
totalItems: number,
) => void;
onLoadMore: (groupKey: string) => void;
}
export const FileList = ({
files,
groupKey,
loadedCount,
onFileClick,
onScroll,
onLoadMore,
}: FileListProps) => {
const visibleFiles = files.slice(0, loadedCount);
const hasMoreItems = files.length > loadedCount;
const handleFileClick = useCallback(
(file: FileItem) => {
onFileClick(file);
},
[onFileClick],
);
const handleLoadMore = useCallback(() => {
onLoadMore(groupKey);
}, [onLoadMore, groupKey]);
const handleScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
onScroll(e, groupKey, files.length);
},
[onScroll, groupKey, files.length],
);
return (
<div className="groupped-status-files">
<div className="groupped-status-files-list" onScroll={handleScroll}>
{visibleFiles.map((file) => (
<div
key={file.id}
className="groupped-status-file-item"
onClick={() => handleFileClick(file)}
>
<span className="groupped-status-file-name">
{file.name}
</span>
<span className="groupped-status-file-path">
{file.path}
</span>
</div>
))}
{hasMoreItems && (
<div className="groupped-status-load-more">
<button
className="groupped-status-load-btn"
onClick={handleLoadMore}
>
Load more... ({files.length - loadedCount}{" "}
remaining)
</button>
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,37 @@
import React from "react";
import { SearchFilter } from "@/components/atoms/SearchFilter";
interface FilterSectionProps {
searchFilter: string;
noteNameFilter: string;
onSearchFilterChange: (value: string) => void;
onNoteNameFilterChange: (value: string) => void;
}
export const FilterSection = ({
searchFilter,
noteNameFilter,
onSearchFilterChange,
onNoteNameFilterChange,
}: FilterSectionProps) => {
return (
<div className="groupped-status-header">
<h3 className="groupped-status-title">Status Groups</h3>
<div className="groupped-status-filters">
<SearchFilter
value={searchFilter}
onFilterChange={onSearchFilterChange}
/>
<div className="groupped-status-note-filter">
<input
type="text"
placeholder="Filter by note name..."
className="groupped-status-note-input"
value={noteNameFilter}
onChange={(e) => onNoteNameFilterChange(e.target.value)}
/>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,75 @@
import React, { useCallback } from "react";
import { StatusBadge } from "@/components/atoms/StatusBadge";
import { CollapsibleCounter } from "@/components/atoms/CollapsibleCounter";
import { FileItem, StatusItem } from "../GrouppedStatusView";
import { FileList } from "./FileList";
import { NoteStatus } from "@/types/noteStatus";
interface StatusGroupProps {
statusName: string;
status: StatusItem;
files: FileItem[];
tag: string;
isExpanded: boolean;
loadedCount: number;
onToggle: () => void;
onFileClick: (file: FileItem) => void;
onScroll: (
e: React.UIEvent<HTMLDivElement>,
groupKey: string,
totalItems: number,
) => void;
onLoadMore: (groupKey: string) => void;
}
export const StatusGroup = ({
statusName,
status,
files,
tag,
isExpanded,
loadedCount,
onToggle,
onFileClick,
onScroll,
onLoadMore,
}: StatusGroupProps) => {
const groupKey = `${tag}-${statusName}`;
const handleToggle = useCallback(() => {
onToggle();
}, [onToggle]);
return (
<div className="groupped-status-group">
<div
className="groupped-status-group-header"
onClick={handleToggle}
>
<StatusBadge
status={
{ ...status, icon: status.icon || "" } as NoteStatus
}
/>
<div className="groupped-status-group-info">
<CollapsibleCounter
count={files.length}
isCollapsed={!isExpanded}
onToggle={handleToggle}
/>
</div>
</div>
{isExpanded && (
<FileList
files={files}
groupKey={groupKey}
loadedCount={loadedCount}
onFileClick={onFileClick}
onScroll={onScroll}
onLoadMore={onLoadMore}
/>
)}
</div>
);
};

View file

@ -0,0 +1,97 @@
import React, { useCallback } from "react";
import { GroupLabel } from "@/components/atoms/GroupLabel";
import { CollapsibleCounter } from "@/components/atoms/CollapsibleCounter";
import { FilesByStatus, FileItem, StatusItem } from "../GrouppedStatusView";
import { StatusGroup } from "./StatusGroup";
interface TagSectionProps {
tag: string;
statusGroups: FilesByStatus;
isExpanded: boolean;
statusMap: Map<string, StatusItem>;
expandedFiles: Set<string>;
getLoadedCount: (groupKey: string) => number;
onToggle: () => void;
onToggleFiles: (groupKey: string) => void;
onFileClick: (file: FileItem) => void;
onScroll: (
e: React.UIEvent<HTMLDivElement>,
groupKey: string,
totalItems: number,
) => void;
onLoadMore: (groupKey: string) => void;
}
export const TagSection = ({
tag,
statusGroups,
isExpanded,
statusMap,
expandedFiles,
getLoadedCount,
onToggle,
onToggleFiles,
onFileClick,
onScroll,
onLoadMore,
}: TagSectionProps) => {
const totalFilesInTag = Object.values(statusGroups).reduce(
(total, files) => total + files.length,
0,
);
const handleToggle = useCallback(() => {
onToggle();
}, [onToggle]);
const handleToggleFiles = useCallback(
(groupKey: string) => {
onToggleFiles(groupKey);
},
[onToggleFiles],
);
return (
<div className="groupped-status-tag-section">
<div className="groupped-status-tag-header" onClick={handleToggle}>
<GroupLabel name={tag} />
<CollapsibleCounter
count={totalFilesInTag}
isCollapsed={!isExpanded}
onToggle={handleToggle}
/>
</div>
{isExpanded && (
<div className="groupped-status-tag-content">
{Object.entries(statusGroups).map(([statusName, files]) => {
if (files.length === 0) return null;
const status = statusMap.get(statusName);
if (!status) return null;
const groupKey = `${tag}-${statusName}`;
const filesExpanded = expandedFiles.has(groupKey);
const loadedCount = getLoadedCount(groupKey);
return (
<StatusGroup
key={statusName}
statusName={statusName}
status={status}
files={files}
tag={tag}
isExpanded={filesExpanded}
loadedCount={loadedCount}
onToggle={() => handleToggleFiles(groupKey)}
onFileClick={onFileClick}
onScroll={onScroll}
onLoadMore={onLoadMore}
/>
);
})}
</div>
)}
</div>
);
};

View file

@ -1,110 +0,0 @@
import React, { useCallback } from "react";
import { App } from "obsidian";
import { Status, NoteStatusSettings } from "../models/types";
import { SettingsUI } from "../integrations/settings/SettingsUI";
import { SettingsUICallbacks } from "../integrations/settings/types";
interface SettingsControllerProps {
app: App;
settings: NoteStatusSettings;
onSettingsChange: (settings: NoteStatusSettings) => Promise<void>;
}
export const SettingsController: React.FC<SettingsControllerProps> = ({
app,
settings,
onSettingsChange,
}) => {
const callbacks: SettingsUICallbacks = {
onTemplateToggle: useCallback(
async (templateId: string, enabled: boolean) => {
const newSettings = { ...settings };
if (enabled) {
if (!newSettings.enabledTemplates.includes(templateId)) {
newSettings.enabledTemplates.push(templateId);
}
} else {
newSettings.enabledTemplates =
newSettings.enabledTemplates.filter(
(id: string) => id !== templateId,
);
}
await onSettingsChange(newSettings);
},
[settings, onSettingsChange],
),
onSettingChange: useCallback(
async (key: keyof NoteStatusSettings, value) => {
console.log("etnra?", key, value);
const newSettings = { ...settings };
newSettings[key] = value;
await onSettingsChange(newSettings);
},
[settings, onSettingsChange],
),
onCustomStatusChange: useCallback(
async (
index: number,
field: keyof Status | "color",
value: string,
) => {
const newSettings = { ...settings };
const status = newSettings.customStatuses[index];
if (!status) return;
if (field === "name") {
const oldName = status.name;
status.name = value;
if (oldName !== status.name) {
newSettings.statusColors[status.name] =
newSettings.statusColors[oldName];
delete newSettings.statusColors[oldName];
}
} else if (field === "color") {
newSettings.statusColors[status.name] = value;
} else {
status[field] = value;
}
await onSettingsChange(newSettings);
},
[settings, onSettingsChange],
),
onCustomStatusRemove: useCallback(
async (index: number) => {
const newSettings = { ...settings };
const status = newSettings.customStatuses[index];
if (!status) return;
newSettings.customStatuses.splice(index, 1);
delete newSettings.statusColors[status.name];
await onSettingsChange(newSettings);
},
[settings, onSettingsChange],
),
onCustomStatusAdd: useCallback(async () => {
const newSettings = { ...settings };
const newStatus: Status = {
name: `status${newSettings.customStatuses.length + 1}`,
icon: "⭐",
};
newSettings.customStatuses.push(newStatus);
newSettings.statusColors[newStatus.name] = "#ffffff";
await onSettingsChange(newSettings);
}, [settings, onSettingsChange]),
};
return <SettingsUI settings={settings} callbacks={callbacks} />;
};
export default SettingsController;

View file

@ -0,0 +1,57 @@
import { PluginSettings } from "@/types/pluginSettings";
import React from "react";
import { SettingItem } from "./SettingItem";
export type Props = {
settings: PluginSettings;
onChange: (key: keyof PluginSettings, value: unknown) => void;
};
export const BehaviourSettings: React.FC<Props> = ({ settings, onChange }) => {
return (
<div>
<h3>Status tag</h3>
<SettingItem
name="Enable multiple statuses"
description="Allow notes to have multiple statuses at the same time"
>
<input
type="checkbox"
checked={settings.useMultipleStatuses}
onChange={(e) =>
onChange("useMultipleStatuses", e.target.checked)
}
/>
</SettingItem>
<SettingItem
name="Status tag prefix"
description="The YAML frontmatter tag name used for status (default: obsidian-note-status)"
>
<input
type="text"
value={settings.tagPrefix}
onChange={(e) => {
if (e.target.value.trim()) {
onChange("tagPrefix", e.target.value.trim());
}
}}
/>
</SettingItem>
<SettingItem
name="Strict status validation"
description="Only show statuses that are defined in templates or custom statuses. ⚠️ WARNING: When enabled, any unknown statuses will be automatically removed when modifying file statuses."
>
<input
type="checkbox"
checked={settings.strictStatuses || false}
onChange={(e) =>
onChange("strictStatuses", e.target.checked)
}
/>
</SettingItem>
</div>
);
};

View file

@ -0,0 +1,91 @@
import { NoteStatus } from "@/types/noteStatus";
import { PluginSettings } from "@/types/pluginSettings";
import React from "react";
export type Props = {
status: NoteStatus;
index: number;
settings: PluginSettings;
onCustomStatusChange: (
index: number,
column: "name" | "icon" | "color" | "description",
value: string,
) => void;
onCustomStatusRemove: (index: number) => void;
};
export const CustomStatusItem: React.FC<Props> = ({
status,
index,
onCustomStatusChange,
onCustomStatusRemove,
}) => {
return (
<div className="custom-status-card">
<div
className="custom-status-preview"
style={{ borderLeftColor: status.color || "var(--text-muted)" }}
>
<input
className="custom-status-icon-input"
type="text"
placeholder="🔥"
value={status.icon}
onChange={(e) =>
onCustomStatusChange(
index,
"icon",
e.target.value || "❓",
)
}
/>
<div className="custom-status-text-inputs">
<input
className="custom-status-name-input"
type="text"
placeholder="Status name"
value={status.name}
onChange={(e) =>
onCustomStatusChange(
index,
"name",
e.target.value || "unnamed",
)
}
style={{ color: status.color || "var(--text-normal)" }}
/>
<input
className="custom-status-description-input"
type="text"
placeholder="Description (optional)"
value={status.description || ""}
onChange={(e) =>
onCustomStatusChange(
index,
"description",
e.target.value,
)
}
/>
</div>
<div className="custom-status-controls">
<input
className="custom-status-color-input"
type="color"
value={status.color || "#ffffff"}
onChange={(e) =>
onCustomStatusChange(index, "color", e.target.value)
}
/>
<button
className="custom-status-remove-btn clickable-icon mod-warning"
onClick={() => onCustomStatusRemove(index)}
aria-label="Remove status"
>
🗑
</button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,94 @@
import { PluginSettings } from "@/types/pluginSettings";
import React from "react";
import {
CustomStatusItem,
Props as CustomStatusItemProps,
} from "./CustomStatusItem";
import { SettingItem } from "./SettingItem";
export type Props = {
settings: PluginSettings;
onChange: (key: keyof PluginSettings, value: unknown) => void;
};
export const CustomStatusSettings: React.FC<Props> = ({
settings,
onChange,
}) => {
const addNewCustomStatus = () => {
const currentStatuses = [...settings.customStatuses];
currentStatuses.push({
name: "",
icon: "",
});
onChange("customStatuses", currentStatuses);
};
const removeCustomStatus: CustomStatusItemProps["onCustomStatusRemove"] = (
index,
) => {
const currentStatuses = [...settings.customStatuses];
currentStatuses.splice(index, 1);
onChange("customStatuses", currentStatuses);
};
const updateCustomStatus: CustomStatusItemProps["onCustomStatusChange"] = (
index,
column,
value,
) => {
const currentStatuses = [...settings.customStatuses];
const target = currentStatuses[index];
if (target) {
target[column] = value;
onChange("customStatuses", currentStatuses);
}
};
return (
<div>
<h3>Custom statuses</h3>
<SettingItem
name="Use only custom statuses"
description="Ignore template statuses and use only the custom statuses defined below"
>
<input
type="checkbox"
checked={settings.useCustomStatusesOnly || false}
onChange={(e) =>
onChange("useCustomStatusesOnly", e.target.checked)
}
/>
</SettingItem>
<SettingItem
name="Custom statuses"
description="Manage the custom statuses"
vertical
>
<div>
{settings.customStatuses.map((status, index) => (
<CustomStatusItem
key={index}
status={status}
index={index}
settings={settings}
onCustomStatusChange={updateCustomStatus}
onCustomStatusRemove={removeCustomStatus}
/>
))}
</div>
</SettingItem>
<SettingItem
name="Add new status"
description="Add an empty custom status to be modified"
>
<button className="mod-cta" onClick={addNewCustomStatus}>
Add Status
</button>
</SettingItem>
</div>
);
};

View file

@ -0,0 +1,7 @@
import React from "react";
export const NoStatusesMessage: React.FC = () => (
<div className="setting-item-description">
No statuses available. Enable templates or add custom statuses first.
</div>
);

View file

@ -0,0 +1,105 @@
import { PluginSettings, StatusTemplate } from "@/types/pluginSettings";
import React, { useCallback, useMemo } from "react";
import { StatusGroup } from "./StatusGroup";
import { NoStatusesMessage } from "./NoStatusesMessage";
export type Props = {
settings: PluginSettings;
onChange: (key: keyof PluginSettings, value: unknown) => void;
templates: StatusTemplate[];
};
type GroupedStatuses = {
customStatuses: PluginSettings["customStatuses"];
templateGroups: Array<{
template: StatusTemplate;
statuses: PluginSettings["customStatuses"];
}>;
};
export const QuickCommandsSettings: React.FC<Props> = ({
settings,
onChange,
templates,
}) => {
const groupedStatuses = useMemo((): GroupedStatuses => {
const customStatuses = settings.customStatuses.filter(
(s) => s.name !== "unknown",
);
const templateGroups = [];
if (!settings.useCustomStatusesOnly) {
for (const templateId of settings.enabledTemplates) {
const template = templates.find((t) => t.id === templateId);
if (template) {
const statuses = template.statuses.filter(
(s) => s.name !== "unknown",
);
if (statuses.length > 0) {
templateGroups.push({ template, statuses });
}
}
}
}
return { customStatuses, templateGroups };
}, [settings, templates]);
const currentQuickCommands = settings.quickStatusCommands || [];
const handleQuickCommandToggle = useCallback(
(statusName: string, enabled: boolean) => {
const updatedCommands = enabled
? [
...currentQuickCommands.filter(
(cmd) => cmd !== statusName,
),
statusName,
]
: currentQuickCommands.filter((cmd) => cmd !== statusName);
onChange("quickStatusCommands", updatedCommands);
},
[currentQuickCommands, onChange],
);
const hasAnyStatuses =
groupedStatuses.customStatuses.length > 0 ||
groupedStatuses.templateGroups.length > 0;
return (
<div className="quick-commands-settings">
<h3>Quick status commands</h3>
<p>
Select which statuses should have dedicated commands in the
command palette. These can be assigned hotkeys for quick access.
</p>
<div className="quick-commands-container">
{!hasAnyStatuses ? (
<NoStatusesMessage />
) : (
<>
{groupedStatuses.customStatuses.length > 0 && (
<StatusGroup
statuses={groupedStatuses.customStatuses}
title="Custom Statuses"
description="Your manually defined statuses"
currentQuickCommands={currentQuickCommands}
onToggle={handleQuickCommandToggle}
/>
)}
{groupedStatuses.templateGroups.map(
({ template, statuses }) => (
<StatusGroup
key={template.id}
statuses={statuses}
title={template.name}
description={template.description}
currentQuickCommands={currentQuickCommands}
onToggle={handleQuickCommandToggle}
/>
),
)}
</>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,27 @@
import React from "react";
interface SettingItemProps {
name: string;
description: string;
vertical?: boolean;
children: React.ReactNode;
}
export const SettingItem: React.FC<SettingItemProps> = ({
name,
description,
children,
vertical,
}) => (
<div className={`setting-item ${vertical && "setting-item-vertical"}`}>
<div className="setting-item-info">
<div className="setting-item-name">{name}</div>
<div className="setting-item-description">{description}</div>
</div>
<div
className={vertical ? "setting-item-full" : "setting-item-control"}
>
{children}
</div>
</div>
);

View file

@ -0,0 +1,45 @@
import React from "react";
import { PluginSettings } from "@/types/pluginSettings";
import { SettingItem } from "./SettingItem";
export type StatusGroupProps = {
statuses: PluginSettings["customStatuses"];
title: string;
description?: string;
currentQuickCommands: string[];
onToggle: (statusName: string, enabled: boolean) => void;
};
export const StatusGroup: React.FC<StatusGroupProps> = ({
statuses,
title,
description,
currentQuickCommands,
onToggle,
}) => (
<div className="status-group">
<div className="status-group-header">
<div className="status-group-title">{title}</div>
{description && (
<div className="status-group-description">{description}</div>
)}
</div>
<div className="status-group-items">
{statuses.map((status) => (
<SettingItem
key={status.name}
name={`${status.icon} ${status.name}`}
description={status.description || ""}
>
<input
type="checkbox"
checked={currentQuickCommands.includes(status.name)}
onChange={(e) =>
onToggle(status.name, e.target.checked)
}
/>
</SettingItem>
))}
</div>
</div>
);

View file

@ -0,0 +1,36 @@
import React from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import { TemplateStatusChip } from "./TemplateStatusChip";
interface TemplateItemProps {
template: StatusTemplate;
isEnabled: boolean;
onToggle: (templateId: string, enabled: boolean) => void;
}
export const TemplateItem: React.FC<TemplateItemProps> = ({
template,
isEnabled,
onToggle,
}) => (
<div
className={`template-item ${isEnabled ? "enabled" : ""}`}
onClick={() => onToggle(template.id, !isEnabled)}
>
<div className="template-header">
<input
type="checkbox"
className="template-checkbox"
checked={isEnabled}
readOnly
/>
<span className="setting-item-name">{template.name}</span>
</div>
<div className="setting-item-description">{template.description}:</div>
<div className="template-statuses">
{template.statuses.map((status, index) => (
<TemplateStatusChip key={index} status={status} />
))}
</div>
</div>
);

View file

@ -0,0 +1,54 @@
import React, { useCallback } from "react";
import { PluginSettings, StatusTemplate } from "@/types/pluginSettings";
import { TemplateItem } from "./TemplateItem";
interface TemplateSettingsProps {
settings: PluginSettings;
onChange: (key: keyof PluginSettings, value: unknown) => void;
templates: StatusTemplate[];
}
export const TemplateSettings: React.FC<TemplateSettingsProps> = ({
settings,
onChange,
templates,
}) => {
const handleTemplateToggle = useCallback(
(templateId: string, enabled: boolean) => {
let newEnabledTemplated = [...settings.enabledTemplates];
if (enabled) {
if (!newEnabledTemplated.includes(templateId)) {
newEnabledTemplated.push(templateId);
}
} else {
newEnabledTemplated = newEnabledTemplated.filter(
(id: string) => id !== templateId,
);
}
onChange("enabledTemplates", newEnabledTemplated);
},
[onChange, settings.enabledTemplates],
);
return (
<div>
<h3>Status templates</h3>
<p>
Enable predefined templates to quickly add common status
workflows
</p>
<div>
{templates.map((template) => (
<TemplateItem
key={template.id}
template={template}
isEnabled={settings.enabledTemplates.includes(
template.id,
)}
onToggle={handleTemplateToggle}
/>
))}
</div>
</div>
);
};

View file

@ -0,0 +1,18 @@
import { NoteStatus } from "@/types/noteStatus";
import React from "react";
interface StatusChipProps {
status: NoteStatus;
}
export const TemplateStatusChip: React.FC<StatusChipProps> = ({ status }) => (
<div className="template-status-chip">
<span
className="template-status-color-dot"
style={{ "--dot-color": status.color } as React.CSSProperties}
/>
<span>
{status.icon} {status.name}
</span>
</div>
);

View file

@ -0,0 +1,88 @@
import { PluginSettings } from "@/types/pluginSettings";
import React from "react";
import { SettingItem } from "./SettingItem";
export type Props = {
settings: PluginSettings;
onChange: (key: keyof PluginSettings, value: unknown) => void;
};
export const UISettings: React.FC<Props> = ({ settings, onChange }) => {
const handleChange =
(key: keyof PluginSettings) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(key, e.target.checked);
};
return (
<div className="ui-settings">
<h3>User interface</h3>
<SettingItem
name="Show status bar"
description="Display the status bar"
>
<input
type="checkbox"
checked={settings.showStatusBar}
onChange={handleChange("showStatusBar")}
/>
</SettingItem>
<SettingItem
name="Auto-hide status bar"
description="Hide the status bar when status is unknown"
>
<input
type="checkbox"
checked={settings.autoHideStatusBar}
onChange={handleChange("autoHideStatusBar")}
/>
</SettingItem>
<SettingItem
name="Show status icons in file explorer"
description="Display status icons in the file explorer"
>
<input
type="checkbox"
checked={settings.showStatusIconsInExplorer}
onChange={handleChange("showStatusIconsInExplorer")}
/>
</SettingItem>
<SettingItem
name="Hide unknown status in file explorer"
description="Hide status icons for files with unknown status in the file explorer"
>
<input
type="checkbox"
checked={settings.hideUnknownStatusInExplorer || false}
onChange={handleChange("hideUnknownStatusInExplorer")}
/>
</SettingItem>
<SettingItem
name="Default to compact view"
description="Start the status pane in compact view by default"
>
<input
type="checkbox"
checked={settings.compactView || false}
onChange={handleChange("compactView")}
/>
</SettingItem>
<SettingItem
name="Exclude unassigned notes from status pane"
description="Improves performance by excluding notes with no assigned status from the status pane. Recommended for large vaults."
>
<input
type="checkbox"
checked={settings.excludeUnknownStatus || false}
onChange={handleChange("excludeUnknownStatus")}
/>
</SettingItem>
</div>
);
};

View file

@ -0,0 +1,45 @@
import { useState, useEffect } from "react";
import { PluginSettings } from "@/types/pluginSettings";
import { UISettings } from "./UISettings";
import { PREDEFINED_TEMPLATES } from "@/constants/defaultSettings";
import { TemplateSettings } from "./TemplateSettings";
import { BehaviourSettings } from "./BehaviourSettings";
import { CustomStatusSettings } from "./CustomStatusSettings";
import { QuickCommandsSettings } from "./QuickCommandsSettings";
export type Props = {
settings: PluginSettings;
onChange: (key: keyof PluginSettings, value: unknown) => void;
};
const SettingsUI: React.FC<Props> = ({ settings, onChange }) => {
const [localSettings, setLocalSettings] = useState(settings);
useEffect(() => {
setLocalSettings(settings);
}, [settings]);
const handleChange = (key: keyof PluginSettings, value: unknown) => {
setLocalSettings((prev) => ({ ...prev, [key]: value }));
onChange(key, value);
};
return (
<div className="note-status-settings">
<TemplateSettings
settings={settings}
onChange={handleChange}
templates={PREDEFINED_TEMPLATES}
/>
<UISettings settings={localSettings} onChange={handleChange} />
<BehaviourSettings settings={settings} onChange={handleChange} />
<CustomStatusSettings settings={settings} onChange={handleChange} />
<QuickCommandsSettings
settings={settings}
onChange={handleChange}
templates={PREDEFINED_TEMPLATES}
/>
</div>
);
};
export default SettingsUI;

View file

@ -0,0 +1,47 @@
import { GroupedStatuses } from "@/types/noteStatus";
import { FC } from "react";
import {
StatusBarProvider,
Props as StatusBarContextProps,
} from "./StatusBarContext";
import { StatusBarGroup } from "./StatusBarGroup";
export type Props = {
statuses: GroupedStatuses;
templates?: { [key: string]: { description: string } };
hideIfNotStatuses?: boolean;
onStatusClick: StatusBarContextProps["onStatusClick"];
};
export const StatusBar: FC<Props> = ({
statuses,
hideIfNotStatuses,
onStatusClick,
}) => {
const statusEntries = Object.entries(statuses);
const hasStatuses = statusEntries.flatMap((s) => s[1]).length > 0;
if (!hasStatuses) {
if (hideIfNotStatuses) return null;
return <span className="status-bar-item"></span>;
}
return (
<StatusBarProvider onStatusClick={onStatusClick}>
<div className="status-bar-group-row">
{statusEntries.map(([frontmatterTagName, statusList]) => (
<StatusBarGroup
key={frontmatterTagName}
statuses={statusList}
template={{
description: "Note status",
name: "",
statuses: statusList,
id: "inANearFutureBeWillGroupStatusesByTemplates",
}}
/>
))}
</div>
</StatusBarProvider>
);
};

View file

@ -0,0 +1,34 @@
import { createContext, useContext, ReactNode } from "react";
import { NoteStatus } from "@/types/noteStatus";
interface StatusBarContextValue {
onStatusClick: (status: NoteStatus) => void;
}
const StatusBarContext = createContext<StatusBarContextValue>({
onStatusClick: () => {},
});
export const useStatusBarContext = () => useContext(StatusBarContext);
export type Props = {
children: ReactNode;
onStatusClick: StatusBarContextValue["onStatusClick"];
};
export const StatusBarProvider: React.FC<Props> = ({
children,
onStatusClick,
}) => {
const value = {
onStatusClick: (status: NoteStatus) => {
onStatusClick(status);
},
};
return (
<StatusBarContext.Provider value={value}>
{children}
</StatusBarContext.Provider>
);
};

View file

@ -0,0 +1,17 @@
import { FC } from "react";
export type Props = {
onEnableClick: () => void;
};
export const StatusBarEnableButton: FC<Props> = ({ onEnableClick }) => {
return (
<span
className="status-bar-item-icon status-bar-enable-button"
data-tooltip-position="top"
onClick={onEnableClick}
>
📊
</span>
);
};

View file

@ -0,0 +1,60 @@
import { FC, useState } from "react";
import { NoteStatus } from "@/types/noteStatus";
import { StatusTemplate } from "@/types/pluginSettings";
import { GroupLabel } from "../atoms/GroupLabel";
import { CollapsibleCounter } from "../atoms/CollapsibleCounter";
import { StatusBadge } from "../atoms/StatusBadge";
import { useStatusBarContext } from "./StatusBarContext";
export interface StatusBarGroupProps {
statuses: NoteStatus[];
template: StatusTemplate;
maxVisible?: number;
}
export const StatusBarGroup: FC<StatusBarGroupProps> = ({
statuses,
template,
maxVisible = 3,
}) => {
const [isUncollapsed, setIsUncollapsed] = useState(false);
const visibleStatuses = isUncollapsed
? statuses
: statuses.slice(0, maxVisible);
const hiddenCount = Math.max(0, statuses.length - maxVisible);
const { onStatusClick } = useStatusBarContext();
return (
<>
<GroupLabel name={template.name} isHighlighted={isUncollapsed} />
<div className="status-bar-group-container">
{visibleStatuses.map((status, i) => (
<div
key={i}
style={{
animation:
isUncollapsed && i >= maxVisible
? `status-slide-in var(--anim-duration-fast) ease-out ${i * 0.05}s both`
: "none",
}}
title={status.description}
>
<StatusBadge
status={status}
onClick={() => {
onStatusClick(status);
}}
/>
</div>
))}
{hiddenCount > 0 && (
<CollapsibleCounter
count={hiddenCount}
isCollapsed={!isUncollapsed}
onToggle={() => setIsUncollapsed(!isUncollapsed)}
/>
)}
</div>
</>
);
};

View file

@ -1,93 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { App } from "obsidian";
import { NoteStatusSettings } from "../models/types";
import { StatusService } from "../services/status-service";
import { StatusBarView } from "./status-bar/StatusBarView";
interface StatusBarControllerProps {
app: App;
settings: NoteStatusSettings;
statusService: StatusService;
initialStatuses?: string[];
}
interface StatusDetails {
name: string;
icon: string;
tooltipText: string;
}
export const StatusBarController: React.FC<StatusBarControllerProps> = ({
app,
settings,
statusService,
initialStatuses = ["unknown"],
}) => {
const [currentStatuses, setCurrentStatuses] =
useState<string[]>(initialStatuses);
const updateStatuses = useCallback((statuses: string[]) => {
setCurrentStatuses(statuses);
}, []);
const shouldShowStatusBar = useCallback((): boolean => {
const onlyUnknown =
currentStatuses.length === 1 && currentStatuses[0] === "unknown";
if (settings.autoHideStatusBar && onlyUnknown) {
return false;
}
return true;
}, [currentStatuses, settings.autoHideStatusBar]);
const getStatusDetails = useCallback((): StatusDetails[] => {
const statusesToShow = settings.useMultipleStatuses
? currentStatuses
: [currentStatuses[0]];
return statusesToShow.map((status) => {
const statusObj = statusService
.getAllStatuses()
.find((s) => s.name === status);
return {
name: status,
icon: statusService.getStatusIcon(status),
tooltipText: statusObj?.description
? `${status} - ${statusObj.description}`
: status,
};
});
}, [currentStatuses, settings.useMultipleStatuses, statusService]);
useEffect(() => {
const handleStatusChanged = (event: CustomEvent) => {
if (event.detail?.statuses) {
updateStatuses(event.detail.statuses);
}
};
window.addEventListener(
"note-status:status-changed",
handleStatusChanged as EventListener,
);
return () => {
window.removeEventListener(
"note-status:status-changed",
handleStatusChanged as EventListener,
);
};
}, [updateStatuses]);
if (!settings.showStatusBar) {
return null;
}
const statusDetails = getStatusDetails();
const isVisible = shouldShowStatusBar();
return <StatusBarView statuses={statusDetails} isVisible={isVisible} />;
};
export default StatusBarController;

View file

@ -0,0 +1,444 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { TFile } from "obsidian";
import { NoteStatus } from "@/types/noteStatus";
import {
BaseNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
import { StatusBadge } from "@/components/atoms/StatusBadge";
import { GroupLabel } from "@/components/atoms/GroupLabel";
import eventBus from "@/core/eventBus";
import settingsService from "@/core/settingsService";
interface VaultStats {
totalNotes: number;
notesWithStatus: number;
statusDistribution: Record<string, number>;
tagDistribution: Record<string, number>;
recentChanges: Array<{
file: TFile;
status: NoteStatus;
timestamp: number;
action: "added" | "removed";
}>;
}
interface CurrentNoteInfo {
file: TFile | null;
statuses: Record<string, NoteStatus[]>;
lastModified: number;
}
export const StatusDashboard = () => {
const [vaultStats, setVaultStats] = useState<VaultStats>({
totalNotes: 0,
notesWithStatus: 0,
statusDistribution: {},
tagDistribution: {},
recentChanges: [],
});
const [currentNote, setCurrentNote] = useState<CurrentNoteInfo>({
file: null,
statuses: {},
lastModified: 0,
});
const [isLoading, setIsLoading] = useState(true);
const calculateVaultStats = useCallback((): VaultStats => {
const files = BaseNoteStatusService.app.vault.getMarkdownFiles();
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
const statusMetadataKeys = [settingsService.settings.tagPrefix];
let notesWithStatus = 0;
const statusDistribution: Record<string, number> = {};
const tagDistribution: Record<string, number> = {};
// Initialize counters
availableStatuses.forEach((status) => {
statusDistribution[status.name] = 0;
});
statusMetadataKeys.forEach((tag) => {
tagDistribution[tag] = 0;
});
files.forEach((file) => {
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(file);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return;
let hasAnyStatus = false;
statusMetadataKeys.forEach((key) => {
const value = frontmatter[key];
if (value) {
hasAnyStatus = true;
tagDistribution[key]++;
const statusNames = Array.isArray(value) ? value : [value];
statusNames.forEach((statusName) => {
const statusStr = statusName.toString();
if (statusDistribution.hasOwnProperty(statusStr)) {
statusDistribution[statusStr]++;
}
});
}
});
if (hasAnyStatus) {
notesWithStatus++;
}
});
return {
totalNotes: files.length,
notesWithStatus,
statusDistribution,
tagDistribution,
recentChanges: [], // TODO: Implement recent changes tracking
};
}, []);
const updateCurrentNote = useCallback(() => {
const activeFile = BaseNoteStatusService.app.workspace.getActiveFile();
if (!activeFile) {
setCurrentNote({ file: null, statuses: {}, lastModified: 0 });
return;
}
const noteStatusService = new NoteStatusService(activeFile);
noteStatusService.populateStatuses();
setCurrentNote({
file: activeFile,
statuses: noteStatusService.statuses,
lastModified: activeFile.stat.mtime,
});
}, []);
const loadData = useCallback(() => {
setIsLoading(true);
try {
const stats = calculateVaultStats();
setVaultStats(stats);
updateCurrentNote();
} finally {
setIsLoading(false);
}
}, [calculateVaultStats, updateCurrentNote]);
useEffect(() => {
loadData();
const handleFileChange = () => {
updateCurrentNote();
};
const handleVaultChange = () => {
loadData();
};
eventBus.subscribe(
"frontmatter-manually-changed",
handleVaultChange,
"status-dashboard-vault-subscription",
);
eventBus.subscribe(
"active-file-change",
handleFileChange,
"status-dashboard-file-subscription",
);
const unsubscribeActiveFile = BaseNoteStatusService.app.workspace.on(
"active-leaf-change",
handleFileChange,
);
return () => {
eventBus.unsubscribe(
"frontmatter-manually-changed",
"status-dashboard-vault-subscription",
);
eventBus.unsubscribe(
"active-file-change",
"status-dashboard-file-subscription",
);
BaseNoteStatusService.app.workspace.offref(unsubscribeActiveFile);
};
}, [loadData, updateCurrentNote]);
const statusChart = useMemo(() => {
const total = Object.values(vaultStats.statusDistribution).reduce(
(sum, count) => sum + count,
0,
);
if (total === 0) return [];
return Object.entries(vaultStats.statusDistribution)
.filter(([_, count]) => count > 0)
.map(([statusName, count]) => ({
name: statusName,
count,
percentage: Math.round((count / total) * 100),
}))
.sort((a, b) => b.count - a.count);
}, [vaultStats.statusDistribution]);
const availableStatuses = BaseNoteStatusService.getAllAvailableStatuses();
const statusMap = new Map(availableStatuses.map((s) => [s.name, s]));
if (isLoading) {
return (
<div className="status-dashboard">
<div className="status-dashboard-loading">
Loading dashboard...
</div>
</div>
);
}
return (
<div className="status-dashboard">
<div className="status-dashboard-header">
<h2 className="status-dashboard-title">Status Dashboard</h2>
<button
className="status-dashboard-refresh"
onClick={loadData}
title="Refresh Dashboard"
>
🔄
</button>
</div>
<div className="status-dashboard-content">
{/* Current Note Section */}
<div className="status-dashboard-section">
<div className="status-dashboard-section-header">
<h3>Current Note</h3>
</div>
<div className="status-dashboard-current-note">
{currentNote.file ? (
<>
<div className="current-note-info">
<div className="current-note-name">
{currentNote.file.basename}
</div>
<div className="current-note-path">
{currentNote.file.path}
</div>
<div className="current-note-modified">
Last modified:{" "}
{new Date(
currentNote.lastModified,
).toLocaleString()}
</div>
</div>
<div className="current-note-statuses">
{Object.entries(currentNote.statuses).map(
([tag, statuses]) => (
<div
key={tag}
className="current-note-tag-group"
>
<GroupLabel name={tag} />
<div className="current-note-status-list">
{statuses.length > 0 ? (
statuses.map(
(status) => (
<StatusBadge
key={
status.name
}
status={
status
}
/>
),
)
) : (
<span className="current-note-no-status">
No status assigned
</span>
)}
</div>
</div>
),
)}
</div>
</>
) : (
<div className="current-note-empty">
No note currently active
</div>
)}
</div>
</div>
{/* Vault Overview Section */}
<div className="status-dashboard-section">
<div className="status-dashboard-section-header">
<h3>Vault Overview</h3>
</div>
<div className="vault-overview">
<div className="vault-stats-grid">
<div className="vault-stat-card">
<div className="vault-stat-number">
{vaultStats.totalNotes}
</div>
<div className="vault-stat-label">
Total Notes
</div>
</div>
<div className="vault-stat-card">
<div className="vault-stat-number">
{vaultStats.notesWithStatus}
</div>
<div className="vault-stat-label">
Notes with Status
</div>
</div>
<div className="vault-stat-card">
<div className="vault-stat-number">
{vaultStats.totalNotes > 0
? Math.round(
(vaultStats.notesWithStatus /
vaultStats.totalNotes) *
100,
)
: 0}
%
</div>
<div className="vault-stat-label">Coverage</div>
</div>
<div className="vault-stat-card">
<div className="vault-stat-number">
{Object.values(
vaultStats.statusDistribution,
).reduce((sum, count) => sum + count, 0)}
</div>
<div className="vault-stat-label">
Total Statuses
</div>
</div>
</div>
</div>
</div>
{/* Status Distribution Section */}
<div className="status-dashboard-section">
<div className="status-dashboard-section-header">
<h3>Status Distribution</h3>
</div>
<div className="status-distribution">
{statusChart.length > 0 ? (
<div className="status-chart">
{statusChart.map(
({ name, count, percentage }) => {
const status = statusMap.get(name);
return (
<div
key={name}
className="status-chart-item"
>
<div className="status-chart-info">
{status && (
<StatusBadge
status={status}
/>
)}
<span className="status-chart-count">
{count} notes (
{percentage}%)
</span>
</div>
<div className="status-chart-bar">
<div
className="status-chart-fill"
style={{
width: `${percentage}%`,
backgroundColor:
status?.color ||
"var(--interactive-accent)",
}}
/>
</div>
</div>
);
},
)}
</div>
) : (
<div className="status-distribution-empty">
No status data available
</div>
)}
</div>
</div>
{/* Quick Actions Section */}
<div className="status-dashboard-section">
<div className="status-dashboard-section-header">
<h3>Quick Actions</h3>
</div>
<div className="quick-actions">
<button
className="quick-action-btn"
onClick={() => {
const leaf =
BaseNoteStatusService.app.workspace.getLeaf();
leaf.setViewState({
type: "groupped-status-view",
active: true,
});
}}
>
📊 View Grouped Statuses
</button>
<button
className="quick-action-btn"
onClick={() => {
// Find notes without status
const files =
BaseNoteStatusService.app.vault.getMarkdownFiles();
const filesWithoutStatus = files.filter(
(file) => {
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(
file,
);
const frontmatter =
cachedMetadata?.frontmatter;
return (
!frontmatter ||
!frontmatter[
settingsService.settings
.tagPrefix
]
);
},
);
console.log(
`Found ${filesWithoutStatus.length} notes without status:`,
filesWithoutStatus.map((f) => f.path),
);
}}
>
🔍 Find Notes Without Status
</button>
<button className="quick-action-btn" onClick={loadData}>
🔄 Refresh Data
</button>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,64 @@
import React from "react";
import { GroupedStatuses } from "@/types/noteStatus";
import { StatusBadge } from "../atoms/StatusBadge";
export interface Props {
statuses: GroupedStatuses;
onClose?: () => void;
}
export const StatusFileInfoPopup: React.FC<Props> = ({ statuses }) => {
const statusEntries = Object.entries(statuses);
if (statusEntries.length === 0) {
return (
<div className="status-popup-empty">
<span className="status-empty-icon">📋</span>
No statuses found
</div>
);
}
return (
<div className="status-info-popup" onClick={(e) => e.stopPropagation()}>
<div className="status-popup-header">
<span className="status-header-icon">📊</span>
Status Overview
</div>
<div className="status-popup-content">
{statusEntries.map(([groupName, statusList]) => (
<div key={groupName} className="status-group">
<div className="status-group-header">
<span className="status-group-name">
{groupName.toLowerCase()}
</span>
<span className="status-group-count">
{statusList.length}
</span>
</div>
<div className="status-group-items">
{statusList.map((status, index) => (
<div
key={`${groupName}-${index}`}
className="status-item"
>
<StatusBadge status={status} />
{status.description && (
<div
className="status-description"
title={status.description}
>
{status.description}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
);
};

View file

@ -0,0 +1,58 @@
import React from "react";
import { GroupedStatuses, NoteStatus } from "@/types/noteStatus";
import { StatusModalHeader } from "./StatusModalHeader";
import {
StatusSelectorGroupedByTag,
Props as SSGByTagProps,
} from "./StatusSelectorGroupedByTag";
export interface Props {
currentStatuses: GroupedStatuses;
filesQuantity: number;
availableStatuses: NoteStatus[];
onRemoveStatus: (
frontmatterTagName: string,
status: NoteStatus,
) => Promise<void>;
onSelectStatus: (
frontmatterTagName: string,
status: NoteStatus,
) => Promise<void>;
}
export const StatusModal: React.FC<Props> = ({
currentStatuses: initialStatuses,
filesQuantity,
availableStatuses,
onRemoveStatus,
onSelectStatus,
}) => {
const currentStatuses = Object.entries(initialStatuses);
const handleSelectedState: SSGByTagProps["onSelectedState"] = (
frontmatterTagName,
status,
action,
) => {
if (action === "select") {
onSelectStatus(frontmatterTagName, status);
} else {
onRemoveStatus(frontmatterTagName, status);
}
};
return (
<div className="note-status-modal-content">
<StatusModalHeader filesQuantity={filesQuantity} />
{currentStatuses.map(([frontmatterTagName, statusList]) => (
<StatusSelectorGroupedByTag
key={frontmatterTagName}
frontmatterTagName={frontmatterTagName}
availableStatuses={availableStatuses}
currentStatuses={statusList}
onSelectedState={handleSelectedState}
/>
))}
</div>
);
};

View file

@ -0,0 +1,74 @@
import { NoteStatus } from "@/types/noteStatus";
import { FC, useState } from "react";
interface Props {
status: NoteStatus;
onRemove: () => void;
}
export const StatusModalChip: FC<Props> = ({ status, onRemove }) => {
const [isRemoving, setIsRemoving] = useState(false);
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
setIsRemoving(true);
setTimeout(() => {
onRemove();
}, 150);
};
return (
<div
className="note-status-chip"
title={
status.description
? `${status.name} - ${status.description}`
: status.name
}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "4px 8px",
background: "var(--interactive-accent)",
color: "var(--text-on-accent)",
borderRadius: "var(--radius-s)",
fontSize: "var(--font-ui-smaller)",
cursor: "pointer",
transition: "all 150ms ease",
opacity: isRemoving ? "0.5" : "1",
}}
>
<span className="note-status-chip-icon">{status.icon}</span>
<span className="note-status-chip-text">{status.name}</span>
<div
className="note-status-chip-remove"
onClick={handleRemove}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "16px",
height: "16px",
borderRadius: "50%",
background: "rgba(255, 255, 255, 0.2)",
cursor: "pointer",
}}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</div>
</div>
);
};

View file

@ -0,0 +1,48 @@
import { FC } from "react";
export type Props = {
filesQuantity: number;
};
export const StatusModalHeader: FC<Props> = ({ filesQuantity }) => {
// TODO: Move the style to its css file
return (
<div className="modal-header" style={{ marginBottom: "16px" }}>
<div
className="modal-title-container"
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div className="modal-title-icon">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
</div>
<span className="modal-title-text">Note status</span>
{filesQuantity > 1 && (
<span
className="modal-title-count"
style={{
opacity: "0.7",
fontSize: "var(--font-ui-smaller)",
}}
>
({filesQuantity} files)
</span>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,89 @@
import { NoteStatus } from "@/types/noteStatus";
import { useState } from "react";
interface StatusOptionProps {
status: NoteStatus;
isSelected: boolean;
onSelect: () => void;
}
export const StatusModalOption: React.FC<StatusOptionProps> = ({
status,
isSelected,
onSelect,
}) => {
const [isHovered, setIsHovered] = useState(false);
const handleClick = () => {
setTimeout(() => {
onSelect();
}, 150);
};
return (
<div
className="note-status-option"
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
title={
status.description
? `${status.name} - ${status.description}`
: undefined
}
style={{
display: "flex",
alignItems: "center",
gap: "12px",
padding: "8px 12px",
cursor: "pointer",
borderBottom: "1px solid var(--background-modifier-border)",
transition: "background-color 150ms ease",
background:
isSelected || isHovered
? "var(--background-modifier-hover)"
: "",
}}
>
<span
className="note-status-option-icon"
style={{
fontSize: "16px",
minWidth: "20px",
}}
>
{status.icon}
</span>
<span
className="note-status-option-text"
style={{
flex: "1",
fontSize: "var(--font-ui-small)",
}}
>
{status.name}
</span>
{isSelected && (
<div
className="note-status-option-check"
style={{
color: "var(--interactive-accent)",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20,6 9,17 4,12" />
</svg>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,57 @@
import React from "react";
import { NoteStatus } from "@/types/noteStatus";
import { StatusModalOption } from "./StatusModalOption";
export interface Props {
currentStatuses: NoteStatus[];
availableStatuses: NoteStatus[];
onToggleStatus: (status: NoteStatus, selected: boolean) => void;
}
export const StatusSelector: React.FC<Props> = ({
currentStatuses,
availableStatuses,
onToggleStatus,
}) => {
const handleSelectStatus = async (status: NoteStatus) => {
const selected =
currentStatuses.findIndex((s) => s.name === status.name) !== -1;
onToggleStatus(status, !selected);
};
// TODO: The StatusSelector must be splitted by its template
// FIXME: fix line 21
return (
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">Available statuses</div>
</div>
<div className="setting-item-control">
<div
className="note-status-options"
style={{
maxHeight: "300px",
overflowY: "auto",
border: "1px solid var(--background-modifier-border)",
borderRadius: "var(--radius-s)",
background: "var(--background-primary)",
width: "300px",
}}
>
{availableStatuses.map((status) => (
<StatusModalOption
key={`${status.name}${status.description}${status.color}${status.icon}`}
status={status}
isSelected={
currentStatuses.findIndex(
(s) => s.name === status.name,
) !== -1
}
onSelect={() => handleSelectStatus(status)}
/>
))}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,97 @@
import React, { useState } from "react";
import { NoteStatus } from "@/types/noteStatus";
import { StatusModalChip } from "./StatusModalChip";
import { SearchFilter } from "../atoms/SearchFilter";
import { StatusSelector } from "./StatusSelector";
export interface Props {
frontmatterTagName: string;
currentStatuses: NoteStatus[];
availableStatuses: NoteStatus[];
onSelectedState: (
frontmatterTagName: string,
status: NoteStatus,
action: "select" | "unselected",
) => void;
}
export const StatusSelectorGroupedByTag: React.FC<Props> = ({
currentStatuses,
availableStatuses,
frontmatterTagName,
onSelectedState,
}) => {
const [searchFilter, setSearchFilter] = useState("");
const filteredStatuses = searchFilter
? availableStatuses.filter((status) =>
status.name.toLowerCase().includes(searchFilter.toLowerCase()),
)
: availableStatuses;
const handleRemoveStatus = async (status: NoteStatus) => {
onSelectedState(frontmatterTagName, status, "unselected");
};
const handleSelectStatus = async (status: NoteStatus) => {
onSelectedState(frontmatterTagName, status, "select");
};
return (
<div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">Current statuses</div>
</div>
<div className="setting-item-control">
<div
className="note-status-chips"
style={{
display: "flex",
flexWrap: "wrap",
gap: "6px",
minHeight: "32px",
alignItems: "center",
}}
>
{currentStatuses.map((s) => (
<StatusModalChip
key={s.name}
status={s}
onRemove={() => handleRemoveStatus(s)}
/>
))}
</div>
</div>
</div>
<SearchFilter
value={searchFilter}
onFilterChange={(value) => setSearchFilter(value)}
/>
{filteredStatuses.length === 0 ? (
<div
style={{
padding: "16px",
textAlign: "center",
color: "var(--text-muted)",
fontStyle: "italic",
}}
>
{searchFilter
? `No statuses match "${searchFilter}"`
: "No statuses found"}
</div>
) : (
<StatusSelector
availableStatuses={filteredStatuses}
currentStatuses={currentStatuses}
onToggleStatus={(status, selected) =>
selected
? handleSelectStatus(status)
: handleRemoveStatus(status)
}
/>
)}
</div>
);
};

View file

@ -1,152 +0,0 @@
import React, { useState, useCallback, useEffect } from "react";
import { TFile, Notice, App } from "obsidian";
import { NoteStatusSettings } from "../models/types";
import { StatusService } from "../services/status-service";
import {
StatusPaneView,
StatusPaneOptions,
} from "../views/status-pane-view/StatusPaneView";
interface StatusPaneViewControllerProps {
app: App;
settings: NoteStatusSettings;
statusService: StatusService;
onSettingsChange?: (settings: NoteStatusSettings) => void;
}
interface PaginationState {
itemsPerPage: number;
currentPage: Record<string, number>;
}
export const StatusPaneViewController: React.FC<
StatusPaneViewControllerProps
> = ({ app, settings, statusService, onSettingsChange }) => {
const [searchQuery, setSearchQuery] = useState("");
const [paginationState, setPaginationState] = useState<PaginationState>({
itemsPerPage: 100,
currentPage: {},
});
const [collapsedStatuses, setCollapsedStatuses] = useState<
Record<string, boolean>
>({});
const [statusGroups, setStatusGroups] = useState<Record<string, TFile[]>>(
{},
);
const [isLoading, setIsLoading] = useState(false);
const updateStatusGroups = useCallback(async () => {
setIsLoading(true);
try {
const groups = statusService.groupFilesByStatus(searchQuery);
setStatusGroups(groups);
} finally {
setIsLoading(false);
}
}, [statusService, searchQuery]);
useEffect(() => {
updateStatusGroups();
}, [updateStatusGroups]);
const handleSearch = useCallback((query: string) => {
setPaginationState({
itemsPerPage: 100,
currentPage: {},
});
setSearchQuery(query);
}, []);
const handleToggleView = useCallback(() => {
if (onSettingsChange) {
const newSettings = {
...settings,
compactView: !settings.compactView,
};
onSettingsChange(newSettings);
window.dispatchEvent(
new CustomEvent("note-status:settings-changed"),
);
}
}, [settings, onSettingsChange]);
const handleRefresh = useCallback(async () => {
await updateStatusGroups();
new Notice("Status pane refreshed");
}, [updateStatusGroups]);
const handleFileClick = useCallback(
(file: TFile) => {
app.workspace.getLeaf().openFile(file);
},
[app],
);
const handleStatusToggle = useCallback(
(status: string, collapsed: boolean) => {
setCollapsedStatuses((prev) => ({
...prev,
[status]: collapsed,
}));
},
[],
);
const handleContextMenu = useCallback((e: MouseEvent, file: TFile) => {
console.log("Context menu for file:", file.path);
}, []);
const handlePageChange = useCallback((status: string, page: number) => {
setPaginationState((prev) => ({
...prev,
currentPage: {
...prev.currentPage,
[status]: page,
},
}));
}, []);
const handleShowUnassigned = useCallback(() => {
if (onSettingsChange) {
const newSettings = {
...settings,
excludeUnknownStatus: false,
};
onSettingsChange(newSettings);
}
}, [settings, onSettingsChange]);
const options: StatusPaneOptions = {
excludeUnknown: settings.excludeUnknownStatus || false,
isCompactView: settings.compactView || false,
collapsedStatuses,
pagination: paginationState,
callbacks: {
onFileClick: handleFileClick,
onStatusToggle: handleStatusToggle,
onContextMenu: handleContextMenu,
onPageChange: handlePageChange,
},
};
const headerCallbacks = {
onSearch: handleSearch,
onToggleView: handleToggleView,
onRefresh: handleRefresh,
};
return (
<StatusPaneView
statusGroups={statusGroups}
options={options}
statusService={statusService}
searchQuery={searchQuery}
isLoading={isLoading}
headerCallbacks={headerCallbacks}
onShowUnassigned={handleShowUnassigned}
/>
);
};
export default StatusPaneViewController;

View file

@ -1,146 +0,0 @@
import React, { useRef } from "react";
import { NoteStatusSettings } from "models/types";
import { StatusService } from "services/status-service";
interface ToolbarButtonProps {
settings: NoteStatusSettings;
statusService: StatusService;
statuses: string[];
onClick?: () => void;
className?: string;
}
interface StatusBadgeProps {
statuses: string[];
settings: NoteStatusSettings;
statusService: StatusService;
}
const StatusBadge: React.FC<StatusBadgeProps> = ({
statuses,
settings,
statusService,
}) => {
const hasValidStatus = statuses.length > 0 && statuses[0] !== "unknown";
if (hasValidStatus) {
const primaryStatus = statuses[0];
const icon = statusService.getStatusIcon(primaryStatus);
return (
<div className="note-status-toolbar-badge-container">
<span
className={`note-status-toolbar-icon status-${primaryStatus}`}
>
{icon}
</span>
{settings.useMultipleStatuses && statuses.length > 1 && (
<span className="note-status-count-badge">
{statuses.length}
</span>
)}
</div>
);
}
return (
<div className="note-status-toolbar-badge-container">
<span className="note-status-toolbar-icon status-unknown">
{statusService.getStatusIcon("unknown")}
</span>
</div>
);
};
export const ToolbarButton: React.FC<ToolbarButtonProps> = ({
settings,
statusService,
statuses,
onClick,
className = "",
}) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const finalClassName =
`note-status-toolbar-button clickable-icon view-action ${className}`.trim();
return (
<button
ref={buttonRef}
className={finalClassName}
aria-label="Note status"
onClick={onClick}
>
<StatusBadge
statuses={statuses}
settings={settings}
statusService={statusService}
/>
</button>
);
};
export class ToolbarButtonManager {
private element: HTMLElement | null = null;
private settings: NoteStatusSettings;
private statusService: StatusService;
private currentStatuses: string[] = ["unknown"];
private onClick: (() => void) | undefined;
constructor(settings: NoteStatusSettings, statusService: StatusService) {
this.settings = settings;
this.statusService = statusService;
}
public createElement(): HTMLElement {
const container = document.createElement("div");
container.addClass("note-status-toolbar-container");
this.element = container;
this.render();
return container;
}
public updateDisplay(statuses: string[]): void {
this.currentStatuses = statuses;
this.render();
}
public setClickHandler(handler: () => void): void {
this.onClick = handler;
this.render();
}
private render(): void {
if (!this.element) return;
// Use ReactUtils to render the React component
import("../utils/react-utils").then(({ ReactUtils }) => {
ReactUtils.render(
React.createElement(ToolbarButton, {
settings: this.settings,
statusService: this.statusService,
statuses: this.currentStatuses,
onClick: this.onClick,
}),
this.element!,
);
});
}
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.render();
}
public destroy(): void {
if (this.element) {
import("../utils/react-utils").then(({ ReactUtils }) => {
ReactUtils.unmount(this.element!);
});
this.element.remove();
this.element = null;
}
}
}
export default ToolbarButton;

View file

@ -0,0 +1,25 @@
import { FC } from "react";
export interface CollapsibleCounterProps {
count: number;
isCollapsed: boolean;
onToggle: React.MouseEventHandler<HTMLSpanElement>;
}
export const CollapsibleCounter: FC<CollapsibleCounterProps> = ({
count,
isCollapsed,
onToggle,
}) => {
if (count <= 0) return null;
return (
<span
className="mod-clickable collapsible-counter-container"
onClick={onToggle}
>
{isCollapsed ? "+" : "-"}
{count}
</span>
);
};

View file

@ -0,0 +1,42 @@
import { FC } from "react";
export interface GroupLabelProps {
name: string;
isHighlighted?: boolean;
}
export const GroupLabel: FC<GroupLabelProps> = ({
name,
isHighlighted = false,
}) => {
if (!name) return null;
return (
<span
className="setting-item-name group-label-container"
style={{
color: isHighlighted
? "var(--text-normal)"
: "var(--text-muted)",
display: "flex",
alignItems: "center",
gap: "0.5rem",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-folder"
>
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
{name}
</span>
);
};

View file

@ -0,0 +1,46 @@
import { FC, useEffect, useRef, useState } from "react";
export type Props = {
value: string;
onFilterChange: (value: string) => void;
};
export const SearchFilter: FC<Props> = ({ value, onFilterChange }) => {
const [searchFilter, setSearchFilter] = useState(value);
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// TODO:: Focus on render?
// if (searchInputRef.current) {
// setTimeout(() => searchInputRef.current?.focus(), 100);
// }
//
setSearchFilter(value);
}, [value]);
// TODO: Move the style to its css file
return (
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">Filter statuses</div>
</div>
<div className="setting-item-control">
<input
ref={searchInputRef}
type="text"
placeholder="Search statuses..."
className="note-status-search-input"
value={searchFilter}
onChange={(e) => onFilterChange(e.target.value)}
style={{
width: "200px",
padding: "6px 12px",
border: "1px solid var(--background-modifier-border)",
borderRadius: "var(--radius-s)",
background: "var(--background-primary)",
color: "var(--text-normal)",
}}
/>
</div>
</div>
);
};

View file

@ -0,0 +1,25 @@
import { FC } from "react";
import { NoteStatus } from "@/types/noteStatus";
export interface StatusBadgeProps {
status: NoteStatus;
onClick?: () => void;
}
export const StatusBadge: FC<StatusBadgeProps> = ({ status, onClick }) => {
return (
<div
className="status-badge-container"
style={{
backgroundColor: `${status.color}15`,
border: `1px solid ${status.color}30`,
}}
onClick={onClick}
>
<div className="status-badge-item">
<span className="status-badge-icon">{status.icon}</span>
<span className="status-badge-text">{status.name}</span>
</div>
</div>
);
};

View file

@ -1,3 +0,0 @@
export { StatusBarController } from "./StatusBarController";
export { SettingsController } from "./SettingsController";
export { StatusPaneViewController } from "./StatusPaneViewController";

View file

@ -1,86 +0,0 @@
import React from "react";
import { setTooltip } from "obsidian";
interface StatusInfo {
name: string;
icon: string;
tooltipText: string;
}
interface StatusBarComponentProps {
statuses: StatusInfo[];
isVisible: boolean;
className?: string;
}
export const StatusBarComponent: React.FC<StatusBarComponentProps> = ({
statuses,
isVisible,
className = "",
}) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const statusRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
React.useEffect(() => {
// Set tooltips after render
statusRefs.current.forEach((ref, index) => {
if (ref && statuses[index]) {
setTooltip(ref, statuses[index].tooltipText);
}
});
}, [statuses]);
if (!isVisible) {
return null;
}
const renderSingleStatus = (status: StatusInfo, index: number) => (
<React.Fragment key={`single-${status.name}`}>
<span
ref={(el) => (statusRefs.current[index * 2] = el)}
className={`note-status-${status.name}`}
>
Status: {status.name}
</span>
<span
ref={(el) => (statusRefs.current[index * 2 + 1] = el)}
className={`note-status-icon status-${status.name}`}
>
{status.icon}
</span>
</React.Fragment>
);
const renderMultipleStatuses = () => (
<React.Fragment>
<span className="note-status-label">Statuses: </span>
<span className="note-status-badges">
{statuses.map((status, index) => (
<span
key={status.name}
ref={(el) => (statusRefs.current[index] = el)}
className={`note-status-badge status-${status.name}`}
>
<span className="note-status-badge-icon">
{status.icon}
</span>
<span className="note-status-badge-text">
{status.name}
</span>
</span>
))}
</span>
</React.Fragment>
);
return (
<div
ref={containerRef}
className={`note-status-bar visible ${className}`}
>
{statuses.length === 1
? renderSingleStatus(statuses[0], 0)
: renderMultipleStatuses()}
</div>
);
};

View file

@ -1,119 +0,0 @@
import React, { useEffect, useRef } from "react";
import { setTooltip } from "obsidian";
interface StatusBarStatus {
name: string;
icon: string;
tooltipText: string;
}
interface StatusBarViewProps {
statuses: StatusBarStatus[];
isVisible: boolean;
className?: string;
}
interface StatusBadgeProps {
status: StatusBarStatus;
}
const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
const badgeRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (badgeRef.current) {
setTooltip(badgeRef.current, status.tooltipText);
}
}, [status.tooltipText]);
return (
<span
ref={badgeRef}
className={`note-status-badge status-${status.name}`}
>
<span className="note-status-badge-icon">{status.icon}</span>
<span className="note-status-badge-text">{status.name}</span>
</span>
);
};
interface SingleStatusProps {
status: StatusBarStatus;
}
const SingleStatus: React.FC<SingleStatusProps> = ({ status }) => {
const statusTextRef = useRef<HTMLSpanElement>(null);
const statusIconRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (statusTextRef.current) {
setTooltip(statusTextRef.current, status.tooltipText);
}
if (statusIconRef.current) {
setTooltip(statusIconRef.current, status.tooltipText);
}
}, [status.tooltipText]);
return (
<>
<span ref={statusTextRef} className={`note-status-${status.name}`}>
Status: {status.name}
</span>
<span
ref={statusIconRef}
className={`note-status-icon status-${status.name}`}
>
{status.icon}
</span>
</>
);
};
interface MultipleStatusesProps {
statuses: StatusBarStatus[];
}
const MultipleStatuses: React.FC<MultipleStatusesProps> = ({ statuses }) => {
return (
<>
<span className="note-status-label">Statuses: </span>
<span className="note-status-badges">
{statuses.map((status, index) => (
<StatusBadge
key={`${status.name}-${index}`}
status={status}
/>
))}
</span>
</>
);
};
export const StatusBarView: React.FC<StatusBarViewProps> = ({
statuses,
isVisible,
className = "",
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const baseClasses = "note-status-bar";
const visibilityClasses = isVisible ? "visible" : "hidden";
const finalClassName =
`${baseClasses} ${visibilityClasses} ${className}`.trim();
if (!isVisible) {
return <div className={finalClassName} ref={containerRef} />;
}
return (
<div className={finalClassName} ref={containerRef}>
{statuses.length === 1 ? (
<SingleStatus status={statuses[0]} />
) : (
<MultipleStatuses statuses={statuses} />
)}
</div>
);
};
export default StatusBarView;

View file

@ -1,4 +0,0 @@
import { StatusBarController } from "./status-bar-controller";
export { StatusBarController as StatusBar };
export default StatusBarController;

View file

@ -1,102 +0,0 @@
import React from "react";
import { NoteStatusSettings } from "../../models/types";
import { StatusService } from "../../services/status-service";
import { StatusBarView } from "./StatusBarView";
import { ReactUtils } from "../../utils/react-utils";
/**
* Controller for the status bar using React
*/
export class StatusBarController {
private container: HTMLElement;
private settings: NoteStatusSettings;
private statusService: StatusService;
private currentStatuses: string[] = ["unknown"];
constructor(
statusBarContainer: HTMLElement,
settings: NoteStatusSettings,
statusService: StatusService,
) {
this.container = statusBarContainer;
this.settings = settings;
this.statusService = statusService;
this.update(["unknown"]);
}
/**
* Update the status bar with new statuses
*/
public update(statuses: string[]): void {
this.currentStatuses = statuses;
this.render();
}
/**
* Render the status bar using React
*/
private render(): void {
if (!this.settings.showStatusBar) {
ReactUtils.unmount(this.container);
return;
}
const statusesToShow = this.settings.useMultipleStatuses
? this.currentStatuses
: [this.currentStatuses[0]];
const statusDetails = statusesToShow.map((status) => {
const statusObj = this.statusService
.getAllStatuses()
.find((s) => s.name === status);
return {
name: status,
icon: this.statusService.getStatusIcon(status),
tooltipText: statusObj?.description
? `${status} - ${statusObj.description}`
: status,
};
});
const shouldShow = this.shouldShowStatusBar();
ReactUtils.render(
React.createElement(StatusBarView, {
statuses: statusDetails,
isVisible: shouldShow,
}),
this.container,
);
}
/**
* Determine if status bar should be visible
*/
private shouldShowStatusBar(): boolean {
const onlyUnknown =
this.currentStatuses.length === 1 &&
this.currentStatuses[0] === "unknown";
if (this.settings.autoHideStatusBar && onlyUnknown) {
return false;
}
return true;
}
/**
* Update settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.render();
}
/**
* Clean up when plugin is unloaded
*/
public unload(): void {
ReactUtils.unmount(this.container);
}
}

View file

@ -1,376 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { App, TFile } from "obsidian";
import {
DropdownDependencies,
StatusRemoveHandler,
StatusSelectHandler,
} from "./types";
import { positionDropdown } from "./dropdown-position";
import { StatusService } from "services/status-service";
import { NoteStatusSettings } from "models/types";
import { ReactUtils } from "../../utils/react-utils";
interface DropdownUIProps {
app: App;
statusService: StatusService;
settings: NoteStatusSettings;
currentStatuses: string[];
targetFile: TFile | null;
targetFiles: TFile[];
isOpen: boolean;
onRemoveStatus: StatusRemoveHandler;
onSelectStatus: StatusSelectHandler;
onClose: () => void;
targetElement?: HTMLElement;
position?: { x: number; y: number };
}
interface StatusItemProps {
status: {
name: string;
icon: string;
description?: string;
};
isSelected: boolean;
onSelect: () => void;
onRemove?: () => void;
showRemove: boolean;
}
const StatusItem: React.FC<StatusItemProps> = ({
status,
isSelected,
onSelect,
onRemove,
showRemove,
}) => {
return (
<div
className={`note-status-dropdown-item ${isSelected ? "selected" : ""}`}
onClick={onSelect}
>
<div className="note-status-item-content">
<span className="note-status-item-icon">{status.icon}</span>
<span className="note-status-item-name">{status.name}</span>
{status.description && (
<span className="note-status-item-description">
{status.description}
</span>
)}
</div>
{showRemove && isSelected && onRemove && (
<button
className="note-status-remove-btn"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
title={`Remove ${status.name} status`}
>
×
</button>
)}
</div>
);
};
interface DropdownContentProps {
settings: NoteStatusSettings;
statusService: StatusService;
currentStatuses: string[];
targetFile: TFile | null;
targetFiles: TFile[];
onRemoveStatus: StatusRemoveHandler;
onSelectStatus: StatusSelectHandler;
}
const DropdownContent: React.FC<DropdownContentProps> = ({
settings,
statusService,
currentStatuses,
targetFile,
targetFiles,
onRemoveStatus,
onSelectStatus,
}) => {
const allStatuses = statusService.getAllStatuses();
const isMultipleFiles = targetFiles.length > 1;
const handleStatusSelect = useCallback(
async (statusName: string) => {
const files =
targetFiles.length > 0
? targetFiles
: targetFile
? [targetFile]
: [];
await onSelectStatus(
statusName,
isMultipleFiles ? files : files[0],
);
},
[targetFile, targetFiles, onSelectStatus, isMultipleFiles],
);
const handleStatusRemove = useCallback(
async (statusName: string) => {
const files =
targetFiles.length > 0
? targetFiles
: targetFile
? [targetFile]
: [];
await onRemoveStatus(
statusName,
isMultipleFiles ? files : files[0],
);
},
[targetFile, targetFiles, onRemoveStatus, isMultipleFiles],
);
if (allStatuses.length === 0) {
return (
<div className="note-status-dropdown-empty">
<p>No statuses available</p>
<p className="note-status-dropdown-help">
Configure statuses in plugin settings
</p>
</div>
);
}
return (
<div className="note-status-dropdown-content">
{isMultipleFiles && (
<div className="note-status-dropdown-header">
<p>Updating {targetFiles.length} files</p>
</div>
)}
<div className="note-status-dropdown-list">
{allStatuses.map((status) => {
const isSelected = currentStatuses.includes(status.name);
return (
<StatusItem
key={status.name}
status={status}
isSelected={isSelected}
onSelect={() => handleStatusSelect(status.name)}
onRemove={() => handleStatusRemove(status.name)}
showRemove={settings.useMultipleStatuses}
/>
);
})}
</div>
</div>
);
};
export const DropdownUI: React.FC<DropdownUIProps> = ({
app,
statusService,
settings,
currentStatuses,
targetFile,
targetFiles,
isOpen,
onRemoveStatus,
onSelectStatus,
onClose,
targetElement,
position,
}) => {
const dropdownRef = useRef<HTMLDivElement>(null);
const [isAnimating, setIsAnimating] = useState(false);
const handleClickOutside = useCallback(
(e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
onClose();
}
},
[onClose],
);
const handleEscapeKey = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
},
[onClose],
);
useEffect(() => {
if (isOpen) {
setIsAnimating(true);
const timer = setTimeout(() => setIsAnimating(false), 220);
document.addEventListener("click", handleClickOutside);
document.addEventListener("keydown", handleEscapeKey);
return () => {
clearTimeout(timer);
document.removeEventListener("click", handleClickOutside);
document.removeEventListener("keydown", handleEscapeKey);
};
}
}, [isOpen, handleClickOutside, handleEscapeKey]);
useEffect(() => {
if (isOpen && dropdownRef.current && targetElement) {
positionDropdown({
dropdownElement: dropdownRef.current,
targetEl: targetElement,
position,
});
}
}, [isOpen, targetElement, position]);
if (!isOpen) {
return null;
}
const className = `note-status-popover note-status-unified-dropdown ${
isAnimating ? "note-status-popover-animate-in" : ""
}`;
return (
<div ref={dropdownRef} className={className}>
<DropdownContent
settings={settings}
statusService={statusService}
currentStatuses={currentStatuses}
targetFile={targetFile}
targetFiles={targetFiles}
onRemoveStatus={onRemoveStatus}
onSelectStatus={onSelectStatus}
/>
</div>
);
};
export class DropdownUIManager {
private app: App;
private statusService: StatusService;
private settings: NoteStatusSettings;
private currentStatuses: string[] = ["unknown"];
private targetFile: TFile | null = null;
private targetFiles: TFile[] = [];
private container: HTMLElement | null = null;
public isOpen = false;
private onRemoveStatus: StatusRemoveHandler = async () => {};
private onSelectStatus: StatusSelectHandler = async () => {};
constructor({ app, settings, statusService }: DropdownDependencies) {
this.app = app;
this.statusService = statusService;
this.settings = settings;
}
public setTargetFile(file: TFile | null): void {
this.targetFile = file;
this.targetFiles = file ? [file] : [];
}
public setTargetFiles(files: TFile[]): void {
this.targetFiles = [...files];
this.targetFile = files.length === 1 ? files[0] : null;
}
public setOnRemoveStatusHandler(handler: StatusRemoveHandler): void {
this.onRemoveStatus = handler;
}
public setOnSelectStatusHandler(handler: StatusSelectHandler): void {
this.onSelectStatus = handler;
}
public updateStatuses(statuses: string[] | string): void {
this.currentStatuses = Array.isArray(statuses)
? [...statuses]
: [statuses];
this.render();
}
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.render();
}
public open(
targetEl: HTMLElement,
position?: { x: number; y: number },
): void {
if (this.isOpen) {
this.close();
setTimeout(() => this.actuallyOpen(targetEl, position), 10);
return;
}
this.actuallyOpen(targetEl, position);
}
private actuallyOpen(
targetEl: HTMLElement,
position?: { x: number; y: number },
): void {
this.isOpen = true;
this.createContainer();
this.render(targetEl, position);
}
private createContainer(): void {
if (!this.container) {
this.container = document.createElement("div");
document.body.appendChild(this.container);
}
}
public close(): void {
this.isOpen = false;
this.render();
setTimeout(() => {
if (this.container) {
ReactUtils.unmount(this.container);
this.container.remove();
this.container = null;
}
}, 220);
}
private render(
targetElement?: HTMLElement,
position?: { x: number; y: number },
): void {
if (!this.container) return;
ReactUtils.render(
React.createElement(DropdownUI, {
app: this.app,
statusService: this.statusService,
settings: this.settings,
currentStatuses: this.currentStatuses,
targetFile: this.targetFile,
targetFiles: this.targetFiles,
isOpen: this.isOpen,
onRemoveStatus: this.onRemoveStatus,
onSelectStatus: this.onSelectStatus,
onClose: () => this.close(),
targetElement,
position,
}),
this.container,
);
}
public dispose(): void {
this.close();
}
}

View file

@ -1,275 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
import { TFile, setTooltip } from "obsidian";
import { StatusService } from "../../services/status-service";
import { NoteStatusSettings, Status } from "../../models/types";
interface StatusDropdownProps {
isOpen: boolean;
currentStatuses: string[];
targetFiles: TFile[];
settings: NoteStatusSettings;
statusService: StatusService;
onRemoveStatus: (status: string, target: TFile | TFile[]) => Promise<void>;
onSelectStatus: (status: string, target: TFile[]) => Promise<void>;
onClose: () => void;
}
export const StatusDropdownComponent: React.FC<StatusDropdownProps> = ({
isOpen,
currentStatuses,
targetFiles,
settings,
statusService,
onRemoveStatus,
onSelectStatus,
onClose,
}) => {
const [searchFilter, setSearchFilter] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen && searchInputRef.current) {
setTimeout(() => searchInputRef.current?.focus(), 50);
}
}, [isOpen]);
if (!isOpen) return null;
const allStatuses = statusService
.getAllStatuses()
.filter((status) => status.name !== "unknown");
const filteredStatuses = searchFilter
? allStatuses.filter(
(status) =>
status.name
.toLowerCase()
.includes(searchFilter.toLowerCase()) ||
status.icon.includes(searchFilter),
)
: allStatuses;
const hasNoValidStatus =
currentStatuses.length === 0 ||
(currentStatuses.length === 1 && currentStatuses[0] === "unknown");
const handleRemoveStatus = async (status: string) => {
const target = targetFiles.length > 1 ? targetFiles : targetFiles[0];
if (target) {
await onRemoveStatus(status, target);
}
};
const handleSelectStatus = async (status: string) => {
if (targetFiles.length > 0) {
await onSelectStatus(status, targetFiles);
}
};
return (
<div className="note-status-popover note-status-unified-dropdown note-status-popover-animate-in">
{/* Header */}
<div className="note-status-popover-header">
<div className="note-status-popover-title">
<div className="note-status-popover-icon">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
</div>
<span className="note-status-popover-label">
Note status
</span>
{targetFiles.length > 1 && (
<span className="note-status-popover-count">
({targetFiles.length} files)
</span>
)}
</div>
</div>
{/* Current Status Chips */}
<div className="note-status-popover-chips">
{hasNoValidStatus ? (
<div className="note-status-empty-indicator">
No status assigned
</div>
) : (
currentStatuses
.filter((status) => status !== "unknown")
.map((status) => {
const statusObj = allStatuses.find(
(s) => s.name === status,
);
if (!statusObj) return null;
return (
<StatusChip
key={status}
status={statusObj}
onRemove={() => handleRemoveStatus(status)}
/>
);
})
)}
</div>
{/* Search Filter */}
<div className="note-status-popover-search">
<input
ref={searchInputRef}
type="text"
placeholder="Filter statuses..."
className="note-status-popover-search-input"
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
/>
</div>
{/* Status Options */}
<div className="note-status-options-container">
{filteredStatuses.length === 0 ? (
<div className="note-status-empty-options">
{searchFilter
? `No statuses match "${searchFilter}"`
: "No statuses found"}
</div>
) : (
filteredStatuses.map((status) => (
<StatusOption
key={status.name}
status={status}
isSelected={currentStatuses.includes(status.name)}
onSelect={() => handleSelectStatus(status.name)}
/>
))
)}
</div>
</div>
);
};
interface StatusChipProps {
status: Status;
onRemove: () => void;
}
const StatusChip: React.FC<StatusChipProps> = ({ status, onRemove }) => {
const chipRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (chipRef.current) {
const tooltipValue = status.description
? `${status.name} - ${status.description}`
: status.name;
setTooltip(chipRef.current, tooltipValue);
}
}, [status]);
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
if (chipRef.current) {
chipRef.current.classList.add("note-status-chip-removing");
setTimeout(() => {
onRemove();
}, 150);
}
};
return (
<div ref={chipRef} className={`note-status-chip status-${status.name}`}>
<span className="note-status-chip-icon">{status.icon}</span>
<span className="note-status-chip-text">{status.name}</span>
<div
className="note-status-chip-remove"
aria-label={`Remove ${status.name} status`}
title={`Remove ${status.name} status`}
onClick={handleRemove}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</div>
</div>
);
};
interface StatusOptionProps {
status: Status;
isSelected: boolean;
onSelect: () => void;
}
const StatusOption: React.FC<StatusOptionProps> = ({
status,
isSelected,
onSelect,
}) => {
const optionRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (optionRef.current && status.description) {
setTooltip(
optionRef.current,
`${status.name} - ${status.description}`,
);
}
}, [status]);
const handleClick = () => {
if (optionRef.current) {
optionRef.current.classList.add("note-status-option-selecting");
setTimeout(() => {
onSelect();
}, 150);
}
};
return (
<div
ref={optionRef}
className={`note-status-option ${
isSelected ? "is-selected" : ""
} status-${status.name}`}
onClick={handleClick}
>
<span className="note-status-option-icon">{status.icon}</span>
<span className="note-status-option-text">{status.name}</span>
{isSelected && (
<div className="note-status-option-check">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20,6 9,17 4,12" />
</svg>
</div>
)}
</div>
);
};

View file

@ -1,322 +0,0 @@
import React from "react";
import { MarkdownView, Editor, Notice, TFile, App } from "obsidian";
import { StatusDropdownComponent } from "./StatusDropdownComponent";
import { DropdownOptions } from "./types";
import { StatusService } from "../../services/status-service";
import { NoteStatusSettings } from "../../models/types";
import { ReactUtils } from "../../utils/react-utils";
/**
* High-level manager for status dropdown interactions using React
*/
export class StatusDropdownManager {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private currentStatuses: string[] = ["unknown"];
private isOpen = false;
private targetFiles: TFile[] = [];
private dropdownContainer: HTMLElement | null = null;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
}
/**
* Updates the dropdown UI based on current statuses
*/
public update(currentStatuses: string[] | string, _file?: TFile): void {
this.currentStatuses = Array.isArray(currentStatuses)
? [...currentStatuses]
: [currentStatuses];
if (this.isOpen) {
this.renderDropdown();
}
}
/**
* Updates settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
if (this.isOpen) {
this.renderDropdown();
}
}
/**
* Get position from cursor or fallback
*/
private getCursorPosition(
editor: Editor,
view: MarkdownView,
): { x: number; y: number } {
try {
const cursor = editor.getCursor("head");
const lineElement = view.contentEl.querySelector(
`.cm-line:nth-child(${cursor.line + 1})`,
);
if (lineElement) {
const rect = lineElement.getBoundingClientRect();
return { x: rect.left + 20, y: rect.bottom + 5 };
}
const editorEl = view.contentEl.querySelector(".cm-editor");
if (editorEl) {
const rect = editorEl.getBoundingClientRect();
return { x: rect.left + 100, y: rect.top + 100 };
}
} catch (error) {
console.error("Error getting position for dropdown:", error);
}
return { x: window.innerWidth / 2, y: window.innerHeight / 3 };
}
/**
* Universal function to open the status dropdown
*/
public openStatusDropdown(options: DropdownOptions): void {
const activeFile = this.app.workspace.getActiveFile();
const files = options.files || (activeFile ? [activeFile] : []);
if (!files.length) {
new Notice("No files selected");
return;
}
if (this.isOpen) {
this.resetDropdown();
return;
}
this.targetFiles = files;
const isSingleFile = files.length === 1;
// Update current statuses based on files
if (isSingleFile) {
this.currentStatuses = this.statusService.getFileStatuses(files[0]);
} else {
this.currentStatuses = this.findCommonStatuses(files);
}
this.positionAndOpenDropdown(options);
}
/**
* Reset dropdown state before opening
*/
public resetDropdown(): void {
this.close();
this.targetFiles = [];
}
/**
* Position and open the dropdown
*/
private positionAndOpenDropdown(options: {
target?: HTMLElement;
position?: { x: number; y: number };
editor?: Editor;
view?: MarkdownView;
}): void {
let position: { x: number; y: number };
if (options.editor && options.view) {
position = this.getCursorPosition(options.editor, options.view);
} else if (options.target) {
if (options.position) {
position = options.position;
} else {
const rect = options.target.getBoundingClientRect();
position = { x: rect.left, y: rect.bottom + 5 };
}
} else if (options.position) {
position = options.position;
} else {
position = { x: window.innerWidth / 2, y: window.innerHeight / 3 };
}
this.open(position);
}
/**
* Open dropdown at a specific position
*/
private open(position: { x: number; y: number }): void {
this.isOpen = true;
// Create container for the dropdown
this.dropdownContainer = document.createElement("div");
this.dropdownContainer.style.position = "fixed";
this.dropdownContainer.style.left = `${position.x}px`;
this.dropdownContainer.style.top = `${position.y}px`;
this.dropdownContainer.style.zIndex = "1000";
document.body.appendChild(this.dropdownContainer);
this.renderDropdown();
// Add event listeners for closing dropdown
setTimeout(() => {
document.addEventListener("click", this.handleClickOutside);
document.addEventListener("keydown", this.handleEscapeKey);
}, 10);
}
/**
* Render the React dropdown component
*/
private renderDropdown(): void {
if (!this.dropdownContainer) return;
ReactUtils.render(
React.createElement(StatusDropdownComponent, {
isOpen: this.isOpen,
currentStatuses: this.currentStatuses,
targetFiles: this.targetFiles,
settings: this.settings,
statusService: this.statusService,
onRemoveStatus: this.handleRemoveStatus.bind(this),
onSelectStatus: this.handleSelectStatus.bind(this),
onClose: this.close.bind(this),
}),
this.dropdownContainer,
);
}
/**
* Handle status removal
*/
private async handleRemoveStatus(
status: string,
target: TFile | TFile[],
): Promise<void> {
const isMultiple = Array.isArray(target);
await this.statusService.handleStatusChange({
files: target,
statuses: status,
operation: "remove",
showNotice: isMultiple,
});
this.close();
}
/**
* Handle status selection
*/
private async handleSelectStatus(
status: string,
targetFiles: TFile[],
): Promise<void> {
const isMultipleFiles = targetFiles.length > 1;
if (isMultipleFiles) {
// Count how many files already have this status
const filesWithStatus = targetFiles.filter((file) =>
this.statusService.getFileStatuses(file).includes(status),
);
// If ALL have the status, remove it. Otherwise, add it
const operation =
filesWithStatus.length === targetFiles.length
? "remove"
: !this.settings.useMultipleStatuses
? "set"
: "add";
await this.statusService.handleStatusChange({
files: targetFiles,
statuses: status,
isMultipleSelection: true,
operation: operation,
});
} else {
// For individual files, maintain default behavior
await this.statusService.handleStatusChange({
files: targetFiles,
statuses: status,
});
}
this.close();
}
/**
* Close the dropdown
*/
private close = (): void => {
this.isOpen = false;
document.removeEventListener("click", this.handleClickOutside);
document.removeEventListener("keydown", this.handleEscapeKey);
if (this.dropdownContainer) {
ReactUtils.unmount(this.dropdownContainer);
this.dropdownContainer.remove();
this.dropdownContainer = null;
}
};
/**
* Handle click outside to close dropdown
*/
private handleClickOutside = (e: MouseEvent): void => {
if (
this.dropdownContainer &&
!this.dropdownContainer.contains(e.target as Node)
) {
this.close();
}
};
/**
* Handle escape key to close dropdown
*/
private handleEscapeKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
this.close();
}
};
/**
* Find common statuses across multiple files
*/
private findCommonStatuses(files: TFile[]): string[] {
if (files.length === 0) return ["unknown"];
const firstFileStatuses = this.statusService.getFileStatuses(files[0]);
return firstFileStatuses.filter(
(status) =>
status !== "unknown" &&
files.every((file) =>
this.statusService.getFileStatuses(file).includes(status),
),
);
}
/**
* Render method (kept for compatibility)
*/
public render(): void {
// No-op - dropdown component handles rendering internally
}
/**
* Remove dropdown when plugin is unloaded
*/
public unload(): void {
this.close();
}
}

View file

@ -1,29 +0,0 @@
/**
* Event handlers setup and cleanup for the dropdown
*/
/**
* Setup event handlers for the dropdown
*/
export function setupDropdownEvents(options: {
clickOutsideHandler: (e: MouseEvent) => void;
escapeKeyHandler: (e: KeyboardEvent) => void;
}): void {
const { clickOutsideHandler, escapeKeyHandler } = options;
document.addEventListener("click", clickOutsideHandler);
document.addEventListener("keydown", escapeKeyHandler);
}
/**
* Remove dropdown event handlers
*/
export function removeDropdownEvents(options: {
clickOutsideHandler: (e: MouseEvent) => void;
escapeKeyHandler: (e: KeyboardEvent) => void;
}): void {
const { clickOutsideHandler, escapeKeyHandler } = options;
document.removeEventListener("click", clickOutsideHandler);
document.removeEventListener("keydown", escapeKeyHandler);
}

View file

@ -1,273 +0,0 @@
import { MarkdownView, Editor, Notice, TFile, App } from "obsidian";
import { DropdownUIManager } from "./DropdownUI";
import { DropdownOptions, DropdownDependencies } from "./types";
import { createDummyTarget } from "./dropdown-position";
import { StatusService } from "services/status-service";
import { NoteStatusSettings } from "models/types";
/**
* High-level manager for status dropdown interactions
*/
export class DropdownManager {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private currentStatuses: string[] = ["unknown"];
private toolbarButton?: HTMLElement;
private dropdownUI: DropdownUIManager;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
const deps: DropdownDependencies = { app, settings, statusService };
this.dropdownUI = new DropdownUIManager(deps);
this.setupDropdownCallbacks();
}
/**
* Set up dropdown callbacks
*/
private setupDropdownCallbacks(): void {
this.dropdownUI.setOnRemoveStatusHandler(async (status, targetFile) => {
if (!targetFile) return;
const isMultiple = Array.isArray(targetFile);
await this.statusService.handleStatusChange({
files: targetFile,
statuses: status,
operation: "remove",
showNotice: isMultiple,
});
});
this.dropdownUI.setOnSelectStatusHandler(async (status, targetFile) => {
// Check if handling multiple files
const isMultipleFiles =
Array.isArray(targetFile) && targetFile.length > 1;
if (isMultipleFiles) {
const files = targetFile as TFile[];
// Count how many files already have this status
const filesWithStatus = files.filter((file) =>
this.statusService.getFileStatuses(file).includes(status),
);
// If ALL have the status, remove it. Otherwise, add it
const operation =
filesWithStatus.length === files.length
? "remove"
: !this.settings.useMultipleStatuses
? "set"
: "add";
await this.statusService.handleStatusChange({
files: targetFile,
statuses: status,
isMultipleSelection: true,
operation: operation,
});
} else {
// For individual files, maintain default behavior
await this.statusService.handleStatusChange({
files: targetFile,
statuses: status,
});
}
});
}
/**
* Updates the dropdown UI based on current statuses
*/
public update(currentStatuses: string[] | string, _file?: TFile): void {
this.currentStatuses = Array.isArray(currentStatuses)
? [...currentStatuses]
: [currentStatuses];
this.dropdownUI.updateStatuses(this.currentStatuses);
}
/**
* Updates settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.dropdownUI.updateSettings(settings);
}
/**
* Get position from cursor or fallback
*/
private getCursorPosition(
editor: Editor,
view: MarkdownView,
): { x: number; y: number } {
try {
const cursor = editor.getCursor("head");
const lineElement = view.contentEl.querySelector(
`.cm-line:nth-child(${cursor.line + 1})`,
);
if (lineElement) {
const rect = lineElement.getBoundingClientRect();
return { x: rect.left + 20, y: rect.bottom + 5 };
}
const editorEl = view.contentEl.querySelector(".cm-editor");
if (editorEl) {
const rect = editorEl.getBoundingClientRect();
return { x: rect.left + 100, y: rect.top + 100 };
}
} catch (error) {
console.error("Error getting position for dropdown:", error);
}
return { x: window.innerWidth / 2, y: window.innerHeight / 3 };
}
/**
* Universal function to open the status dropdown
*/
public openStatusDropdown(options: DropdownOptions): void {
const activeFile = this.app.workspace.getActiveFile();
const files = options.files || (activeFile ? [activeFile] : []);
if (!files.length) {
new Notice("No files selected");
return;
}
if (!files.length || !files[0]) {
new Notice("No files selected");
return;
}
if (this.dropdownUI.isOpen) {
this.resetDropdown();
return;
}
const isSingleFile = files.length === 1;
// Set up target files appropriately
if (isSingleFile) {
const targetFile = files[0];
this.dropdownUI.setTargetFile(targetFile);
const currentStatuses =
this.statusService.getFileStatuses(targetFile);
this.dropdownUI.updateStatuses(currentStatuses);
} else {
// For multiple files, set the whole collection
this.dropdownUI.setTargetFiles(files);
const commonStatuses = this.findCommonStatuses(files);
this.dropdownUI.updateStatuses(commonStatuses);
}
this.positionAndOpenDropdown(options);
}
/**
* Reset dropdown state before opening
*/
public resetDropdown(): void {
this.dropdownUI.close();
this.dropdownUI.setTargetFile(null);
}
/**
* Position and open the dropdown
*/
private positionAndOpenDropdown(options: {
target?: HTMLElement;
position?: { x: number; y: number };
editor?: Editor;
view?: MarkdownView;
}): void {
if (options.editor && options.view) {
const position = this.getCursorPosition(
options.editor,
options.view,
);
this.openWithPosition(position);
return;
}
if (options.target) {
if (options.position) {
this.dropdownUI.open(options.target, options.position);
} else {
const rect = options.target.getBoundingClientRect();
this.dropdownUI.open(options.target, {
x: rect.left,
y: rect.bottom + 5,
});
}
return;
}
if (options.position) {
this.openWithPosition(options.position);
return;
}
this.openWithPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 3,
});
}
/**
* Open dropdown at a specific position using dummy target
*/
private openWithPosition(position: { x: number; y: number }): void {
const dummyTarget = createDummyTarget(position);
this.dropdownUI.open(dummyTarget, position);
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
}, 100);
}
/**
* Find common statuses across multiple files
*/
private findCommonStatuses(files: TFile[]): string[] {
if (files.length === 0) return ["unknown"];
const firstFileStatuses = this.statusService.getFileStatuses(files[0]);
return firstFileStatuses.filter(
(status) =>
status !== "unknown" &&
files.every((file) =>
this.statusService.getFileStatuses(file).includes(status),
),
);
}
/**
* Render method (kept for compatibility)
*/
public render(): void {
// No-op - dropdown component handles rendering internally
}
/**
* Remove dropdown when plugin is unloaded
*/
public unload(): void {
this.dropdownUI.dispose();
if (this.toolbarButton) {
this.toolbarButton.remove();
this.toolbarButton = undefined;
}
}
}

View file

@ -1,123 +0,0 @@
/**
* Dropdown positioning utilities
*/
/**
* Position the dropdown
*/
export function positionDropdown(options: {
dropdownElement: HTMLElement;
targetEl: HTMLElement;
position?: { x: number; y: number };
}): void {
const { dropdownElement, targetEl, position } = options;
if (position) {
positionAt(dropdownElement, position.x, position.y);
} else {
positionRelativeTo(dropdownElement, targetEl);
}
}
/**
* Position the dropdown at specific coordinates
*/
function positionAt(dropdownElement: HTMLElement, x: number, y: number): void {
dropdownElement.addClass("note-status-popover-fixed");
dropdownElement.style.setProperty("--pos-x-px", `${x}px`);
dropdownElement.style.setProperty("--pos-y-px", `${y}px`);
setTimeout(() => adjustPositionToViewport(dropdownElement), 0);
}
/**
* Adjust the dropdown position to ensure it's visible in the viewport
*/
function adjustPositionToViewport(dropdownElement: HTMLElement): void {
const rect = dropdownElement.getBoundingClientRect();
if (rect.right > window.innerWidth) {
dropdownElement.addClass("note-status-popover-right");
dropdownElement.style.setProperty("--right-offset-px", "10px");
} else {
dropdownElement.removeClass("note-status-popover-right");
}
if (rect.bottom > window.innerHeight) {
dropdownElement.addClass("note-status-popover-bottom");
dropdownElement.style.setProperty("--bottom-offset-px", "10px");
} else {
dropdownElement.removeClass("note-status-popover-bottom");
}
const maxHeight = window.innerHeight - rect.top - 20;
dropdownElement.style.setProperty("--max-height-px", `${maxHeight}px`);
}
/**
* Position the dropdown relative to a target element
*/
function positionRelativeTo(
dropdownElement: HTMLElement,
targetEl: HTMLElement,
): void {
dropdownElement.addClass("note-status-popover-fixed");
const targetRect = targetEl.getBoundingClientRect();
dropdownElement.style.setProperty(
"--pos-y-px",
`${targetRect.bottom + 5}px`,
);
dropdownElement.style.setProperty("--pos-x-px", `${targetRect.left}px`);
setTimeout(() => adjustRelativePosition(dropdownElement, targetRect), 0);
}
/**
* Adjust position when positioned relative to an element
*/
function adjustRelativePosition(
dropdownElement: HTMLElement,
targetRect: DOMRect,
): void {
const rect = dropdownElement.getBoundingClientRect();
if (rect.right > window.innerWidth) {
dropdownElement.addClass("note-status-popover-right");
dropdownElement.style.setProperty(
"--right-offset-px",
`${window.innerWidth - targetRect.right}px`,
);
} else {
dropdownElement.removeClass("note-status-popover-right");
}
if (rect.bottom > window.innerHeight) {
dropdownElement.addClass("note-status-popover-bottom");
dropdownElement.style.setProperty(
"--bottom-offset-px",
`${window.innerHeight - targetRect.top + 5}px`,
);
} else {
dropdownElement.removeClass("note-status-popover-bottom");
}
const maxHeight = window.innerHeight - rect.top - 20;
dropdownElement.style.setProperty("--max-height-px", `${maxHeight}px`);
}
/**
* Create a dummy target element for positioning
*/
export function createDummyTarget(position: {
x: number;
y: number;
}): HTMLElement {
const dummyTarget = document.createElement("div");
dummyTarget.addClass("note-status-dummy-target");
dummyTarget.style.setProperty("--pos-x-px", `${position.x}px`);
dummyTarget.style.setProperty("--pos-y-px", `${position.y}px`);
document.body.appendChild(dummyTarget);
return dummyTarget;
}

View file

@ -1,6 +0,0 @@
import { StatusDropdownManager } from "./StatusDropdownManager";
import { DropdownOptions } from "./types";
export type { DropdownOptions };
export { StatusDropdownManager as StatusDropdown };
export default StatusDropdownManager;

View file

@ -1,42 +0,0 @@
// # Tipos comunes
import { NoteStatusSettings } from "models/types";
import { App, TFile, Editor, MarkdownView } from "obsidian";
import { StatusService } from "services/status-service";
/**
* Options for opening status dropdown
*/
export interface DropdownOptions {
target?: HTMLElement;
position?: { x: number; y: number };
files?: TFile[];
editor?: Editor;
view?: MarkdownView;
mode?: "replace" | "add" | "remove" | "toggle";
onStatusChange?: (statuses: string[]) => void;
}
/**
* Status removal handler function type
*/
export type StatusRemoveHandler = (
status: string,
targetFile?: TFile | TFile[],
) => Promise<void>;
/**
* Status selection handler function type
*/
export type StatusSelectHandler = (
status: string,
targetFile: TFile | TFile[],
) => Promise<void>;
/**
* Common dependencies for dropdown components
*/
export interface DropdownDependencies {
app: App;
settings: NoteStatusSettings;
statusService: StatusService;
}

View file

@ -1,18 +1,5 @@
import { Status } from "../models/types";
import { PluginSettings, StatusTemplate } from "types/pluginSettings";
/**
* Status Template interface
*/
export interface StatusTemplate {
id: string;
name: string;
description: string;
statuses: Status[];
}
/**
* Predefined status templates
*/
export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
{
id: "colorful",
@ -73,7 +60,26 @@ export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
},
];
/**
* Default template IDs that should be enabled by default
*/
export const DEFAULT_ENABLED_TEMPLATES = ["colorful"];
export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
statusColors: {
active: "var(--text-success)",
onHold: "var(--text-warning)",
completed: "var(--text-accent)",
dropped: "var(--text-error)",
unknown: "var(--text-muted)",
},
showStatusBar: true,
autoHideStatusBar: false,
customStatuses: [],
showStatusIconsInExplorer: true,
hideUnknownStatusInExplorer: false, // Default to show unknown status
collapsedStatuses: {},
compactView: false,
enabledTemplates: [PREDEFINED_TEMPLATES[0].id],
useCustomStatusesOnly: false,
useMultipleStatuses: true,
tagPrefix: "obsidian-note-status",
strictStatuses: false, // Default to show all statuses from frontmatter
excludeUnknownStatus: true, // Default to exclude unknown status files for better performance
quickStatusCommands: ["active", "completed"], // Add default quick commands
};

View file

@ -1,40 +0,0 @@
import { NoteStatusSettings } from "../models/types";
import { DEFAULT_ENABLED_TEMPLATES } from "../constants/status-templates";
/**
* Default plugin settings
*/
export const DEFAULT_SETTINGS: NoteStatusSettings = {
statusColors: {
active: "var(--text-success)",
onHold: "var(--text-warning)",
completed: "var(--text-accent)",
dropped: "var(--text-error)",
unknown: "var(--text-muted)",
},
showStatusBar: true,
autoHideStatusBar: false,
customStatuses: [],
showStatusIconsInExplorer: true,
hideUnknownStatusInExplorer: false, // Default to show unknown status
collapsedStatuses: {},
compactView: false,
enabledTemplates: DEFAULT_ENABLED_TEMPLATES,
useCustomStatusesOnly: false,
useMultipleStatuses: true,
tagPrefix: "obsidian-note-status",
strictStatuses: false, // Default to show all statuses from frontmatter
excludeUnknownStatus: true, // Default to exclude unknown status files for better performance
quickStatusCommands: ["active", "completed"], // Add default quick commands
};
/**
* Default colors in hexadecimal format for backup and reset
*/
export const DEFAULT_COLORS: Record<string, string> = {
active: "#00ff00", // Green for success
onHold: "#ffaa00", // Orange for warning
completed: "#00aaff", // Blue for accent
dropped: "#ff0000", // Red for error
unknown: "#888888", // Gray for muted
};

View file

@ -1,24 +0,0 @@
/**
* SVG icon definitions
*/
export const ICONS = {
statusPane: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h10v2H7v-2z"/>
</svg>`,
search: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="search-icon">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>`,
clear: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>`,
standardView: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`,
compactView: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="3" x2="9" y2="21"></line></svg>`,
refresh: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38"/></svg>`,
collapseDown: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>`,
collapseRight: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>`,
file: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
};

393
core/commandsService.ts Normal file
View file

@ -0,0 +1,393 @@
import { Plugin, Notice, Editor, MarkdownView, TFile, App } from "obsidian";
import { NoteStatusService, BaseNoteStatusService } from "./noteStatusService";
import settingsService from "./settingsService";
import eventBus from "./eventBus";
export class CommandsService {
private plugin: Plugin;
private app: App;
private registeredCommands: Set<string> = new Set();
constructor(plugin: Plugin) {
this.plugin = plugin;
this.app = plugin.app;
}
private createStatusService(file: TFile): NoteStatusService {
const service = new NoteStatusService(file);
service.populateStatuses();
return service;
}
registerAllCommands(): void {
// Change status of current note
this.plugin.addCommand({
id: "change-status",
name: "Change status of current note",
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
const statusService = this.createStatusService(file);
statusService.populateStatuses();
eventBus.publish("triggered-open-modal", {
statusService: statusService,
});
}
return true;
},
});
this.registeredCommands.add("change-status");
// Insert status metadata
this.plugin.addCommand({
id: "insert-status-metadata",
name: "Insert status metadata",
editorCheckCallback: (
checking: boolean,
editor: Editor,
view: MarkdownView,
) => {
if (!view.file) return false;
const cache = this.plugin.app.metadataCache.getFileCache(
view.file,
);
const frontmatter = cache?.frontmatter;
// Check if any frontmatter property starts with tagPrefix
let hasFronttmatter = false;
if (frontmatter) {
hasFronttmatter = Object.keys(frontmatter).some((key) =>
key.startsWith(settingsService.settings.tagPrefix),
);
}
if (!checking && !hasFronttmatter) {
BaseNoteStatusService.insertStatusMetadataInEditor(
view.file,
).then(() => {
new Notice("Status metadata inserted");
});
}
return !hasFronttmatter;
},
});
this.registeredCommands.add("insert-status-metadata");
// Cycle through statuses
this.plugin.addCommand({
id: "cycle-status",
name: "Cycle to next status",
checkCallback: (checking: boolean) => {
if (settingsService.settings.useMultipleStatuses) {
// For now the cycle status is for single statuses
return false;
}
const file = this.app.workspace.getActiveFile();
if (!file) {
return false;
}
if (
!checking &&
!settingsService.settings.useMultipleStatuses
) {
const allStatuses =
BaseNoteStatusService.getAllAvailableStatuses().map(
(s) => s.name,
);
if (allStatuses.length === 0) {
new Notice("No statuses available");
return;
}
// Get the current file data
const statusService = this.createStatusService(file);
statusService.populateStatuses();
const statuses =
statusService.statuses[
settingsService.settings.tagPrefix
] || [];
const currentStatus = statuses.length
? statuses?.[0].name
: undefined;
let nextIndex = 0;
if (currentStatus) {
const currentIndex = allStatuses.indexOf(currentStatus);
if (currentIndex !== -1) {
nextIndex =
currentIndex === -1
? 0
: (currentIndex + 1) % allStatuses.length;
}
}
statusService
.addStatus(
settingsService.settings.tagPrefix,
allStatuses[nextIndex],
)
.then((resolve) => {
new Notice(
`Status changed to ${allStatuses[nextIndex]}`,
);
});
}
return true;
},
});
this.registeredCommands.add("cycle-status");
// Register dynamic quick status commands
this.registerQuickStatusCommands();
// Clear status
this.plugin.addCommand({
id: "clear-status",
name: "Clear status",
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
const statusService = this.createStatusService(file);
statusService
.clearStatus(settingsService.settings.tagPrefix)
.then(() => {
new Notice("Status cleared");
});
}
return true;
},
});
this.registeredCommands.add("clear-status");
// Copy status from current note
this.plugin.addCommand({
id: "copy-status",
name: "Copy status from current note",
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
const statuses = this.getFileStatuses(file);
navigator.clipboard
.writeText(statuses.join(", "))
.then(() => {
new Notice(`Copied status: ${statuses.join(", ")}`);
});
}
return true;
},
});
this.registeredCommands.add("copy-status");
// Paste status to current note
this.plugin.addCommand({
id: "paste-status",
name: "Paste status to current note",
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
const statusService = this.createStatusService(file);
statusService.populateStatuses();
navigator.clipboard.readText().then((text) => {
const statuses = text.split(", ");
this.setFileStatuses(file, statuses);
statusService
.overrideStatuses(
settingsService.settings.tagPrefix,
statuses,
)
.then(() => {
new Notice(
`Pasted status: ${statuses.join(", ")}`,
);
});
});
}
return true;
},
});
this.registeredCommands.add("paste-status");
// Toggle multiple statuses mode
this.plugin.addCommand({
id: "toggle-multiple-statuses",
name: "Toggle multiple statuses mode",
callback: () => {
const currentValue =
settingsService.settings.useMultipleStatuses;
settingsService.setValue("useMultipleStatuses", !currentValue);
new Notice(
`Multiple statuses mode ${!currentValue ? "enabled" : "disabled"}`,
);
},
});
this.registeredCommands.add("toggle-multiple-statuses");
// Search notes by status
this.plugin.addCommand({
id: "search-by-status",
name: "Search notes by current status",
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
const statuses = this.getFileStatuses(file);
if (!statuses || statuses.length === 0) return false;
const tagPrefix = settingsService.settings.tagPrefix;
const queries = statuses.map(
(status) => `[${tagPrefix}:"${status}"]`,
);
const query = queries.join(" OR ");
// @ts-ignore
this.app.internalPlugins.plugins[
"global-search"
].instance.openGlobalSearch(query);
}
return true;
},
});
this.registeredCommands.add("search-by-status");
}
/**
* Register quick status commands based on settings
*/
private registerQuickStatusCommands(): void {
const quickCommands =
settingsService.settings.quickStatusCommands || [];
const allStatuses = BaseNoteStatusService.getAllAvailableStatuses();
quickCommands.forEach((statusName) => {
const status = allStatuses.find((s) => s.name === statusName);
if (!status) return;
this.plugin.addCommand({
id: `set-status-${statusName}`,
name: `Set status to ${statusName}`,
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
this.setFileStatuses(file, [statusName]);
new Notice(`Status set to ${statusName}`);
}
return true;
},
});
this.registeredCommands.add(`set-status-${statusName}`);
// Add toggle command for multiple status mode
if (settingsService.settings.useMultipleStatuses) {
this.plugin.addCommand({
id: `toggle-status-${statusName}`,
name: `Toggle status ${statusName}`,
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
this.toggleFileStatus(file, statusName);
const currentStatuses = this.getFileStatuses(file);
const hasStatus =
currentStatuses.includes(statusName);
new Notice(
`Status ${statusName} ${hasStatus ? "added" : "removed"}`,
);
}
return true;
},
});
this.registeredCommands.add(`toggle-status-${statusName}`);
}
});
}
private getFileStatuses(file: TFile): string[] {
const statusService = this.createStatusService(file);
statusService.populateStatuses();
const tagPrefix = settingsService.settings.tagPrefix;
const statuses = statusService.statuses[tagPrefix] || [];
return statuses.length > 0 ? statuses.map((s) => s.name) : ["unknown"];
}
private async setFileStatuses(
file: TFile,
statusNames: string[],
): Promise<void> {
const tagPrefix = settingsService.settings.tagPrefix;
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
if (
statusNames.length === 0 ||
(statusNames.length === 1 && statusNames[0] === "unknown")
) {
delete frontmatter[tagPrefix];
} else if (statusNames.length === 1) {
frontmatter[tagPrefix] = statusNames[0];
} else {
frontmatter[tagPrefix] = statusNames;
}
});
eventBus.publish("frontmatter-manually-changed", { file });
}
private async toggleFileStatus(
file: TFile,
statusName: string,
): Promise<void> {
const currentStatuses = this.getFileStatuses(file);
let newStatuses: string[];
if (currentStatuses.includes(statusName)) {
// Remove the status
newStatuses = currentStatuses.filter((s) => s !== statusName);
} else {
// Add the status
newStatuses = [
...currentStatuses.filter((s) => s !== "unknown"),
statusName,
];
}
await this.setFileStatuses(file, newStatuses);
}
public unload(): void {
// Remove existing commands
this.removeQuickStatusCommands();
}
/**
* Remove all quick status commands
*/
private removeQuickStatusCommands(): void {
const allStatuses = BaseNoteStatusService.getAllAvailableStatuses();
allStatuses.forEach((status) => {
// @ts-ignore
this.app.commands.removeCommand(
`note-status:set-status-${status.name}`,
);
// @ts-ignore
this.app.commands.removeCommand(
`note-status:toggle-status-${status.name}`,
);
});
}
destroy(): void {
// Commands are automatically cleaned up when the plugin is disabled
// But we can track them for debugging purposes
this.registeredCommands.clear();
}
}

50
core/eventBus.ts Normal file
View file

@ -0,0 +1,50 @@
import { EventBusEvents, EventName } from "@/types/eventBus";
class EventBus {
private events: { [K in EventName]: Map<string, EventBusEvents[K]> } = {
"active-file-change": new Map(),
"plugin-settings-changed": new Map(),
"frontmatter-manually-changed": new Map(),
"triggered-open-modal": new Map(),
};
subscribe<T extends EventName>(
event: T,
callback: EventBusEvents[T],
id: string,
) {
this.events[event].set(id, callback);
return () => {
this.events[event].delete(id);
};
}
unsubscribe(event: EventName, id: string) {
this.events[event].delete(id);
}
publish(
event: "active-file-change",
...args: Parameters<EventBusEvents["active-file-change"]>
): void;
publish(
event: "plugin-settings-changed",
...args: Parameters<EventBusEvents["plugin-settings-changed"]>
): void;
publish(
event: "frontmatter-manually-changed",
...args: Parameters<EventBusEvents["frontmatter-manually-changed"]>
): void;
publish(
event: "triggered-open-modal",
...args: Parameters<EventBusEvents["triggered-open-modal"]>
): void;
publish(event: EventName, ...args: unknown[]) {
const callbacks = this.events[event];
callbacks.forEach((callback) => {
(callback as (...args: unknown[]) => void)(...args);
});
}
}
export default new EventBus();

156
core/lazyElementObserver.ts Normal file
View file

@ -0,0 +1,156 @@
/**
* Interface for element processing callbacks
*/
export interface IElementProcessor {
processElement(element: HTMLElement): void;
}
/**
* Service for observing DOM changes in file explorer
* Single responsibility: DOM observation and element detection
*/
export class LazyElementObserver {
private mutationObserver: MutationObserver | null = null;
private intersectionObserver: IntersectionObserver | null = null;
private processedElements = new WeakSet<HTMLElement>();
private elementProcessor: IElementProcessor;
private readonly INTERSECTION_ROOT_MARGIN = "50px";
private readonly FILE_SELECTOR = "[data-path]";
constructor(elementProcessor: IElementProcessor) {
this.elementProcessor = elementProcessor;
}
/**
* Sets up observers for a container element
*/
setupObservers(container: Element): void {
this.cleanup();
this.initializeIntersectionObserver();
this.initializeMutationObserver(container);
this.observeExistingElements(container);
}
/**
* Marks element as unprocessed for re-processing
*/
markElementForReprocessing(element: HTMLElement): void {
this.processedElements.delete(element);
}
/**
* Cleanup all observers and reset state
*/
cleanup(): void {
this.disconnectObservers();
this.resetState();
}
/**
* Initializes intersection observer for visibility detection
*/
private initializeIntersectionObserver(): void {
this.intersectionObserver = new IntersectionObserver(
(entries) => this.handleIntersectionEntries(entries),
{ rootMargin: this.INTERSECTION_ROOT_MARGIN },
);
}
/**
* Handles intersection observer entries
*/
private handleIntersectionEntries(
entries: IntersectionObserverEntry[],
): void {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const element = entry.target as HTMLElement;
this.processElementIfNeeded(element);
}
});
}
/**
* Processes element if not already processed
*/
private processElementIfNeeded(element: HTMLElement): void {
if (!this.processedElements.has(element)) {
this.elementProcessor.processElement(element);
this.processedElements.add(element);
}
}
/**
* Initializes mutation observer for DOM changes
*/
private initializeMutationObserver(container: Element): void {
this.mutationObserver = new MutationObserver((mutations) =>
this.handleMutations(mutations),
);
this.mutationObserver.observe(container, {
childList: true,
subtree: true,
});
}
/**
* Handles mutation observer entries
*/
private handleMutations(mutations: MutationRecord[]): void {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
this.handleAddedElement(node as HTMLElement);
}
});
});
}
/**
* Handles newly added DOM elements
*/
private handleAddedElement(element: HTMLElement): void {
this.observeElement(element);
const fileElements = element.querySelectorAll(this.FILE_SELECTOR);
fileElements.forEach((el) => this.observeElement(el as HTMLElement));
}
/**
* Observes existing elements in container
*/
private observeExistingElements(container: Element): void {
const fileElements = container.querySelectorAll(this.FILE_SELECTOR);
fileElements.forEach((el) => this.observeElement(el as HTMLElement));
}
/**
* Starts observing a single element
*/
private observeElement(element: HTMLElement): void {
const dataPath = element.getAttribute("data-path");
if (dataPath && this.intersectionObserver) {
this.intersectionObserver.observe(element);
}
}
/**
* Disconnects all observers
*/
private disconnectObservers(): void {
this.mutationObserver?.disconnect();
this.intersectionObserver?.disconnect();
}
/**
* Resets internal state
*/
private resetState(): void {
this.mutationObserver = null;
this.intersectionObserver = null;
this.processedElements = new WeakSet();
}
}

391
core/noteStatusService.ts Normal file
View file

@ -0,0 +1,391 @@
import { App, TFile } from "obsidian";
import {
GroupedStatuses,
NoteStatus,
NoteStatus as NoteStatusType,
} from "@/types/noteStatus";
import { PREDEFINED_TEMPLATES } from "@/constants/defaultSettings";
import settingsService from "@/core/settingsService";
import eventBus from "./eventBus";
export abstract class BaseNoteStatusService {
static app: App;
statuses: GroupedStatuses;
constructor() {
this.statuses = {};
}
static initialize(app: App) {
BaseNoteStatusService.app = app;
}
private static allEnabledTemplatesStatuses(): NoteStatusType[] {
if (settingsService.settings.useCustomStatusesOnly) {
return [];
}
const enabledTemplates = PREDEFINED_TEMPLATES.filter((template) =>
settingsService.settings.enabledTemplates.includes(template.id),
);
return enabledTemplates.flatMap((t) => t.statuses);
}
static getAllAvailableStatuses(): NoteStatus[] {
const availableStatuses = [
...settingsService.settings.customStatuses,
...BaseNoteStatusService.allEnabledTemplatesStatuses(),
];
return availableStatuses;
}
static async insertStatusMetadataInEditor(file: TFile): Promise<void> {
const tagPrefix = settingsService.settings.tagPrefix;
await BaseNoteStatusService.app.fileManager.processFrontMatter(
file,
(frontmatter) => {
const noteStatusFrontmatter =
(frontmatter?.[tagPrefix] as string[]) || [];
if (!noteStatusFrontmatter.length) {
frontmatter[tagPrefix] = [];
}
},
);
}
protected statusNameToObject(statusName: NoteStatusType["name"]) {
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
const s = availableStatuses.find((f) => f.name === statusName);
return s;
}
protected getStatusMetadataKeys(): string[] {
return [settingsService.settings.tagPrefix];
}
abstract populateStatuses(): void;
abstract removeStatus(
frontmatterTagName: string,
status: NoteStatus,
): Promise<boolean>;
abstract addStatus(
frontmatterTagName: string,
statusIdentifier: NoteStatus["name"],
): Promise<boolean>;
}
export class NoteStatusService extends BaseNoteStatusService {
private file: TFile;
constructor(file: TFile) {
super();
this.file = file;
}
public populateStatuses(): void {
const STATUS_METADATA_KEYS = this.getStatusMetadataKeys();
this.statuses = Object.fromEntries(
STATUS_METADATA_KEYS.map((key) => [key, []]),
);
if (!this.file) {
return;
}
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(this.file);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) {
return;
}
STATUS_METADATA_KEYS.forEach((key) => {
const value = frontmatter[key];
if (value) {
let statuses: (NoteStatusType | undefined)[] = [];
if (Array.isArray(value)) {
statuses = value.map((v) =>
this.statusNameToObject(v.toString()),
);
} else {
statuses = [this.statusNameToObject(value.toString())];
}
statuses = statuses.filter((s) => s !== undefined);
if (
statuses.length &&
!settingsService.settings.useMultipleStatuses
) {
statuses = statuses.slice(0, 1);
}
this.statuses[key] = statuses as NoteStatusType[];
}
});
}
async removeStatus(
frontmatterTagName: string,
status: NoteStatus,
): Promise<boolean> {
let removed = false;
await BaseNoteStatusService.app.fileManager.processFrontMatter(
this.file,
(frontmatter) => {
const noteStatusFrontmatter =
(frontmatter?.[frontmatterTagName] as string[]) || [];
if (!noteStatusFrontmatter.length) return;
if (Array.isArray(noteStatusFrontmatter)) {
const i = noteStatusFrontmatter.findIndex(
(statusName: string) => statusName === status.name,
);
if (i !== -1) {
noteStatusFrontmatter.splice(i, 1);
removed = true;
}
}
},
);
if (removed) {
eventBus.publish("frontmatter-manually-changed", {
file: this.file,
});
}
return removed;
}
async clearStatus(frontmatterTagName: string): Promise<boolean> {
await BaseNoteStatusService.app.fileManager.processFrontMatter(
this.file,
(frontmatter) => {
if (frontmatterTagName in frontmatter) {
delete frontmatter[frontmatterTagName];
}
},
);
eventBus.publish("frontmatter-manually-changed", { file: this.file });
return true;
}
async overrideStatuses(
frontmatterTagName: string,
statusIdentifiers: NoteStatus["name"][],
): Promise<boolean> {
await BaseNoteStatusService.app.fileManager.processFrontMatter(
this.file,
(frontmatter) => {
if (
frontmatterTagName in frontmatter &&
Array.isArray(frontmatter[frontmatterTagName])
) {
frontmatter[frontmatterTagName].splice(0);
frontmatter[frontmatterTagName].push(...statusIdentifiers);
}
frontmatter[frontmatterTagName] = [...statusIdentifiers];
},
);
eventBus.publish("frontmatter-manually-changed", { file: this.file });
return true;
}
async addStatus(
frontmatterTagName: string,
statusIdentifier: NoteStatus["name"],
): Promise<boolean> {
let added = false;
await BaseNoteStatusService.app.fileManager.processFrontMatter(
this.file,
(frontmatter) => {
const noteStatusFrontmatter =
(frontmatter?.[frontmatterTagName] as string[]) || [];
if (!settingsService.settings.useMultipleStatuses) {
frontmatter[frontmatterTagName] = statusIdentifier;
added = true;
} else {
const i = noteStatusFrontmatter.findIndex(
(statusName: string) => statusName === statusIdentifier,
);
if (i === -1) {
frontmatter[frontmatterTagName].push(statusIdentifier);
added = true;
}
}
},
);
if (added) {
eventBus.publish("frontmatter-manually-changed", {
file: this.file,
});
}
return added;
}
}
export class MultipleNoteStatusService extends BaseNoteStatusService {
private files: TFile[];
constructor(files: TFile[]) {
super();
this.files = files;
}
selectedFilesQTY() {
return this.files.length;
}
public populateStatuses(): void {
const STATUS_METADATA_KEYS = this.getStatusMetadataKeys();
this.statuses = Object.fromEntries(
STATUS_METADATA_KEYS.map((key) => [key, []]),
);
const allStatuses = new Map<string, Set<string>>();
this.files.forEach((file) => {
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(file);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return;
STATUS_METADATA_KEYS.forEach((key) => {
const value = frontmatter[key];
if (value) {
if (!allStatuses.has(key)) {
allStatuses.set(key, new Set());
}
const statusNames = Array.isArray(value) ? value : [value];
statusNames.forEach((name) =>
allStatuses.get(key)!.add(name.toString()),
);
}
});
});
STATUS_METADATA_KEYS.forEach((key) => {
const statusNames = allStatuses.get(key);
if (statusNames) {
const statuses = Array.from(statusNames)
.map((name) => this.statusNameToObject(name))
.filter((s) => s !== undefined) as NoteStatusType[];
this.statuses[key] = statuses;
}
});
}
async removeStatus(
frontmatterTagName: string,
status: NoteStatus,
): Promise<boolean> {
let removedFromAny = false;
const promises = this.files.map(async (file) => {
let removed = false;
await BaseNoteStatusService.app.fileManager.processFrontMatter(
file,
(frontmatter) => {
const noteStatusFrontmatter =
frontmatter[frontmatterTagName];
if (!noteStatusFrontmatter) return;
if (Array.isArray(noteStatusFrontmatter)) {
const index = noteStatusFrontmatter.findIndex(
(statusName: string) => statusName === status.name,
);
if (index !== -1) {
noteStatusFrontmatter.splice(index, 1);
removed = true;
}
} else if (noteStatusFrontmatter === status.name) {
delete frontmatter[frontmatterTagName];
removed = true;
}
},
);
return removed;
});
const results = await Promise.all(promises);
removedFromAny = results.some((result) => result);
if (removedFromAny) {
this.files.forEach((file) => {
eventBus.publish("frontmatter-manually-changed", { file });
});
}
return removedFromAny;
}
async addStatus(
frontmatterTagName: string,
statusIdentifier: NoteStatus["name"],
): Promise<boolean> {
let addedToAny = false;
const promises = this.files.map(async (file) => {
let added = false;
await BaseNoteStatusService.app.fileManager.processFrontMatter(
file,
(frontmatter) => {
const noteStatusFrontmatter =
frontmatter[frontmatterTagName];
if (!noteStatusFrontmatter) {
frontmatter[frontmatterTagName] = [statusIdentifier];
added = true;
} else if (Array.isArray(noteStatusFrontmatter)) {
if (!noteStatusFrontmatter.includes(statusIdentifier)) {
noteStatusFrontmatter.push(statusIdentifier);
added = true;
}
} else {
if (noteStatusFrontmatter !== statusIdentifier) {
frontmatter[frontmatterTagName] = [
noteStatusFrontmatter,
statusIdentifier,
];
added = true;
}
}
},
);
return added;
});
const results = await Promise.all(promises);
addedToAny = results.some((result) => result);
if (addedToAny) {
this.files.forEach((file) => {
eventBus.publish("frontmatter-manually-changed", { file });
});
}
return addedToAny;
}
getFilesWithStatus(
frontmatterTagName: string,
status: NoteStatus,
): TFile[] {
return this.files.filter((file) => {
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(file);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return false;
const value = frontmatter[frontmatterTagName];
if (!value) return false;
if (Array.isArray(value)) {
return value.includes(status.name);
}
return value === status.name;
});
}
}

18
core/selectorService.ts Normal file
View file

@ -0,0 +1,18 @@
import { App } from "obsidian";
import {
MultipleNoteStatusService,
NoteStatusService,
} from "./noteStatusService";
class SelectorService {
static app: App;
public noteStatusService: NoteStatusService | MultipleNoteStatusService;
constructor(
noteStatusService: NoteStatusService | MultipleNoteStatusService,
) {
this.noteStatusService = noteStatusService;
}
}
export default SelectorService;

45
core/settingsService.ts Normal file
View file

@ -0,0 +1,45 @@
import { DEFAULT_PLUGIN_SETTINGS } from "@/constants/defaultSettings";
import { PluginSettings } from "@/types/pluginSettings";
import { Plugin } from "obsidian";
import eventBus from "./eventBus";
class SettingsService {
private plugin: Plugin;
public settings: PluginSettings;
constructor() {}
async initialize(plugin: Plugin): Promise<void> {
this.plugin = plugin;
await this.loadSettings();
}
setValue(key: keyof PluginSettings, value: unknown) {
if (!(key in this.settings)) {
throw new Error(`The "${key}" setting is not a known setting key`);
}
this.settings[key] = value;
this.saveSettings().catch(console.error);
// INFO: Send the propgation event
eventBus.publish("plugin-settings-changed", {
key,
value,
currentSettings: this.settings,
});
}
private async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_PLUGIN_SETTINGS,
await this.plugin.loadData(),
);
return this.settings;
}
private async saveSettings() {
await this.plugin.saveData(this.settings);
}
}
export default new SettingsService();

View file

@ -1,3 +0,0 @@
export { useStatusService } from "./useStatusService";
export { useStyleService } from "./useStyleService";
export { useDropdownManager } from "./useDropdownManager";

View file

@ -1,289 +0,0 @@
import { useState, useCallback, useRef } from "react";
import { App, TFile, MarkdownView, Editor, Notice } from "obsidian";
import { NoteStatusSettings } from "../models/types";
import { StatusService } from "../services/status-service";
import {
DropdownOptions,
StatusRemoveHandler,
StatusSelectHandler,
} from "../components/status-dropdown/types";
import { createDummyTarget } from "../components/status-dropdown/dropdown-position";
import { DropdownUIManager } from "../components/status-dropdown/DropdownUI";
interface UseDropdownManagerOptions {
app: App;
settings: NoteStatusSettings;
statusService: StatusService;
}
export const useDropdownManager = ({
app,
settings,
statusService,
}: UseDropdownManagerOptions) => {
const [currentStatuses, setCurrentStatuses] = useState<string[]>([
"unknown",
]);
const dropdownUIRef = useRef<DropdownUIManager | null>(null);
const initializeDropdownUI = useCallback(() => {
if (!dropdownUIRef.current) {
const deps = { app, settings, statusService };
dropdownUIRef.current = new DropdownUIManager(deps);
const onRemoveStatusHandler: StatusRemoveHandler = async (
status,
targetFile,
) => {
if (!targetFile) return;
const isMultiple = Array.isArray(targetFile);
await statusService.handleStatusChange({
files: targetFile,
statuses: status,
operation: "remove",
showNotice: isMultiple,
});
};
const onSelectStatusHandler: StatusSelectHandler = async (
status,
targetFile,
) => {
const isMultipleFiles =
Array.isArray(targetFile) && targetFile.length > 1;
if (isMultipleFiles) {
const files = targetFile as TFile[];
const filesWithStatus = files.filter((file) =>
statusService.getFileStatuses(file).includes(status),
);
const operation =
filesWithStatus.length === files.length
? "remove"
: !settings.useMultipleStatuses
? "set"
: "add";
await statusService.handleStatusChange({
files: targetFile,
statuses: status,
isMultipleSelection: true,
operation: operation,
});
} else {
await statusService.handleStatusChange({
files: targetFile,
statuses: status,
});
}
};
dropdownUIRef.current.setOnRemoveStatusHandler(
onRemoveStatusHandler,
);
dropdownUIRef.current.setOnSelectStatusHandler(
onSelectStatusHandler,
);
}
return dropdownUIRef.current;
}, [app, settings, statusService]);
const update = useCallback(
(newStatuses: string[] | string, _file?: TFile): void => {
const statuses = Array.isArray(newStatuses)
? [...newStatuses]
: [newStatuses];
setCurrentStatuses(statuses);
const dropdownUI = initializeDropdownUI();
dropdownUI.updateStatuses(statuses);
},
[initializeDropdownUI],
);
const updateSettings = useCallback(
(newSettings: NoteStatusSettings): void => {
const dropdownUI = initializeDropdownUI();
dropdownUI.updateSettings(newSettings);
},
[initializeDropdownUI],
);
const getCursorPosition = useCallback(
(editor: Editor, view: MarkdownView): { x: number; y: number } => {
try {
const cursor = editor.getCursor("head");
const lineElement = view.contentEl.querySelector(
`.cm-line:nth-child(${cursor.line + 1})`,
);
if (lineElement) {
const rect = lineElement.getBoundingClientRect();
return { x: rect.left + 20, y: rect.bottom + 5 };
}
const editorEl = view.contentEl.querySelector(".cm-editor");
if (editorEl) {
const rect = editorEl.getBoundingClientRect();
return { x: rect.left + 100, y: rect.top + 100 };
}
} catch (error) {
console.error("Error getting position for dropdown:", error);
}
return { x: window.innerWidth / 2, y: window.innerHeight / 3 };
},
[],
);
const findCommonStatuses = useCallback(
(files: TFile[]): string[] => {
if (files.length === 0) return ["unknown"];
const firstFileStatuses = statusService.getFileStatuses(files[0]);
return firstFileStatuses.filter(
(status) =>
status !== "unknown" &&
files.every((file) =>
statusService.getFileStatuses(file).includes(status),
),
);
},
[statusService],
);
const resetDropdown = useCallback((): void => {
const dropdownUI = initializeDropdownUI();
dropdownUI.close();
dropdownUI.setTargetFile(null);
}, [initializeDropdownUI]);
const openWithPosition = useCallback(
(position: { x: number; y: number }): void => {
const dummyTarget = createDummyTarget(position);
const dropdownUI = initializeDropdownUI();
dropdownUI.open(dummyTarget, position);
setTimeout(() => {
if (dummyTarget.parentNode) {
dummyTarget.parentNode.removeChild(dummyTarget);
}
}, 100);
},
[initializeDropdownUI],
);
const positionAndOpenDropdown = useCallback(
(options: {
target?: HTMLElement;
position?: { x: number; y: number };
editor?: Editor;
view?: MarkdownView;
}): void => {
const dropdownUI = initializeDropdownUI();
if (options.editor && options.view) {
const position = getCursorPosition(
options.editor,
options.view,
);
openWithPosition(position);
return;
}
if (options.target) {
if (options.position) {
dropdownUI.open(options.target, options.position);
} else {
const rect = options.target.getBoundingClientRect();
dropdownUI.open(options.target, {
x: rect.left,
y: rect.bottom + 5,
});
}
return;
}
if (options.position) {
openWithPosition(options.position);
return;
}
openWithPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 3,
});
},
[initializeDropdownUI, getCursorPosition, openWithPosition],
);
const openStatusDropdown = useCallback(
(options: DropdownOptions): void => {
const activeFile = app.workspace.getActiveFile();
const files = options.files || (activeFile ? [activeFile] : []);
if (!files.length) {
new Notice("No files selected");
return;
}
if (!files.length || !files[0]) {
new Notice("No files selected");
return;
}
const dropdownUI = initializeDropdownUI();
if (dropdownUI.isOpen) {
resetDropdown();
return;
}
const isSingleFile = files.length === 1;
if (isSingleFile) {
const targetFile = files[0];
dropdownUI.setTargetFile(targetFile);
const fileStatuses = statusService.getFileStatuses(targetFile);
dropdownUI.updateStatuses(fileStatuses);
} else {
dropdownUI.setTargetFiles(files);
const commonStatuses = findCommonStatuses(files);
dropdownUI.updateStatuses(commonStatuses);
}
positionAndOpenDropdown(options);
},
[
app,
initializeDropdownUI,
statusService,
resetDropdown,
findCommonStatuses,
positionAndOpenDropdown,
],
);
const render = useCallback((): void => {
// No-op - dropdown component handles rendering internally
}, []);
const unload = useCallback((): void => {
if (dropdownUIRef.current) {
dropdownUIRef.current.dispose();
dropdownUIRef.current = null;
}
}, []);
return {
currentStatuses,
update,
updateSettings,
openStatusDropdown,
resetDropdown,
render,
unload,
};
};

View file

@ -1,405 +0,0 @@
import { useState, useCallback, useMemo, useEffect } from "react";
import { App, TFile, Editor, Notice } from "obsidian";
import { NoteStatusSettings, Status } from "../models/types";
import { PREDEFINED_TEMPLATES } from "../constants/status-templates";
interface UseStatusServiceOptions {
app: App;
settings: NoteStatusSettings;
}
export const useStatusService = ({
app,
settings,
}: UseStatusServiceOptions) => {
const [allStatuses, setAllStatuses] = useState<Status[]>([]);
const updateAllStatuses = useCallback(() => {
const newStatuses = [...settings.customStatuses];
if (!settings.useCustomStatusesOnly) {
const templateStatuses = settings.enabledTemplates
.map((id) => PREDEFINED_TEMPLATES.find((t) => t.id === id))
.filter(Boolean)
.flatMap((template) => (template ? template.statuses : []));
for (const status of templateStatuses) {
const existingIndex = newStatuses.findIndex(
(s) => s.name.toLowerCase() === status.name.toLowerCase(),
);
if (existingIndex === -1) {
newStatuses.push(status);
} else if (status.color) {
if (!settings.statusColors[status.name]) {
settings.statusColors[status.name] = status.color;
}
}
}
}
setAllStatuses(newStatuses);
}, [settings]);
useEffect(() => {
updateAllStatuses();
}, [updateAllStatuses]);
const getAllStatuses = useCallback(() => allStatuses, [allStatuses]);
const getTemplateStatuses = useCallback((): Status[] => {
return settings.enabledTemplates
.map((id) => PREDEFINED_TEMPLATES.find((t) => t.id === id))
.filter(Boolean)
.flatMap((template) => (template ? template.statuses : []));
}, [settings.enabledTemplates]);
const getFileStatuses = useCallback(
(file: TFile): string[] => {
const cachedMetadata = app.metadataCache.getFileCache(file);
if (!cachedMetadata?.frontmatter) return ["unknown"];
const frontmatterStatus =
cachedMetadata.frontmatter[settings.tagPrefix];
if (!frontmatterStatus) return ["unknown"];
const statuses: string[] = [];
const addValidStatus = (
statusName: string,
targetStatuses: string[],
): void => {
const normalizedStatus = statusName.toLowerCase();
const matchingStatus = allStatuses.find(
(s) => s.name.toLowerCase() === normalizedStatus,
);
if (matchingStatus) {
targetStatuses.push(matchingStatus.name);
}
};
if (Array.isArray(frontmatterStatus)) {
for (const statusName of frontmatterStatus) {
if (settings.strictStatuses) {
addValidStatus(statusName.toString(), statuses);
} else {
const cleanStatus = statusName.toString().trim();
if (cleanStatus) statuses.push(cleanStatus);
}
}
} else {
if (settings.strictStatuses) {
addValidStatus(frontmatterStatus.toString(), statuses);
} else {
const cleanStatus = frontmatterStatus.toString().trim();
if (cleanStatus) statuses.push(cleanStatus);
}
}
return statuses.length > 0 ? statuses : ["unknown"];
},
[app, settings, allStatuses],
);
const getStatusIcon = useCallback(
(status: string): string => {
const customStatus = allStatuses.find(
(s) => s.name.toLowerCase() === status.toLowerCase(),
);
return customStatus ? customStatus.icon : "❓";
},
[allStatuses],
);
const insertStatusMetadataInEditor = useCallback(
(editor: Editor): void => {
const content = editor.getValue();
const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/);
const statusTagRegex = new RegExp(
`${settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`,
"m",
);
const insertIntoExistingFrontmatter = (
content: string,
frontMatterMatch: RegExpMatchArray,
statusMetadata: string,
): void => {
const frontMatter = frontMatterMatch[1];
const updatedFrontMatter = frontMatter.match(statusTagRegex)
? frontMatter.replace(statusTagRegex, statusMetadata)
: `${frontMatter}\n${statusMetadata}`;
const updatedContent = content.replace(
/^---\n([\s\S]+?)\n---/,
`---\n${updatedFrontMatter}\n---`,
);
editor.setValue(updatedContent);
};
const createFrontmatterWithStatus = (
content: string,
statusMetadata: string,
): void => {
const newFrontMatter = `---\n${statusMetadata}\n---\n\n${content.trim()}`;
editor.setValue(newFrontMatter);
};
if (frontMatterMatch) {
const frontMatter = frontMatterMatch[1];
if (frontMatter.match(statusTagRegex)) {
return;
}
const defaultStatuses = ["unknown"];
const statusMetadata = `${settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
insertIntoExistingFrontmatter(
content,
frontMatterMatch,
statusMetadata,
);
} else {
if (content.match(statusTagRegex)) {
return;
}
const defaultStatuses = ["unknown"];
const statusMetadata = `${settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
createFrontmatterWithStatus(content, statusMetadata);
}
},
[settings],
);
const getMarkdownFiles = useCallback(
(searchQuery = ""): TFile[] => {
const files = app.vault.getMarkdownFiles();
if (!searchQuery) return files;
const lowerQuery = searchQuery.toLowerCase();
return files.filter((file) =>
file.basename.toLowerCase().includes(lowerQuery),
);
},
[app],
);
const groupFilesByStatus = useCallback(
(searchQuery = ""): Record<string, TFile[]> => {
const statusGroups: Record<string, TFile[]> = {};
for (const status of allStatuses) {
statusGroups[status.name] = [];
}
statusGroups["unknown"] = statusGroups["unknown"] || [];
const files = getMarkdownFiles(searchQuery);
for (const file of files) {
const statuses = getFileStatuses(file);
for (const status of statuses) {
if (statusGroups[status]) {
statusGroups[status].push(file);
}
}
}
return statusGroups;
},
[allStatuses, getMarkdownFiles, getFileStatuses],
);
const modifyNoteStatus = useCallback(
async (options: {
files: TFile | TFile[];
statuses: string | string[];
operation: "set" | "add" | "remove" | "toggle";
showNotice?: boolean;
}): Promise<void> => {
const { operation, showNotice = true } = options;
const targetFiles = Array.isArray(options.files)
? options.files
: [options.files];
const targetStatuses = Array.isArray(options.statuses)
? options.statuses
: [options.statuses];
if (targetFiles.length === 0) {
if (showNotice) new Notice("No files selected");
return;
}
const updatePromises = targetFiles.map(async (file) => {
if (!file || file.extension !== "md") return;
const currentStatuses = getFileStatuses(file);
let newStatuses: string[] = [];
switch (operation) {
case "set":
newStatuses = [...targetStatuses];
break;
case "add":
newStatuses = [
...new Set([
...currentStatuses.filter(
(s) => s !== "unknown",
),
...targetStatuses,
]),
];
break;
case "remove":
newStatuses = currentStatuses.filter(
(status) => !targetStatuses.includes(status),
);
break;
case "toggle":
newStatuses = [...currentStatuses];
for (const status of targetStatuses) {
if (currentStatuses.includes(status)) {
newStatuses = newStatuses.filter(
(s) => s !== status,
);
} else {
newStatuses = [
...newStatuses.filter(
(s) => s !== "unknown",
),
status,
];
}
}
break;
}
if (newStatuses.length === 0) {
newStatuses = ["unknown"];
}
await app.fileManager.processFrontMatter(
file,
(frontmatter) => {
frontmatter[settings.tagPrefix] = newStatuses;
},
);
window.dispatchEvent(
new CustomEvent("note-status:status-changed", {
detail: {
statuses: newStatuses,
file: file?.path,
},
}),
);
});
await Promise.all(updatePromises);
if (showNotice && targetFiles.length > 1) {
const statusText =
targetStatuses.length === 1
? targetStatuses[0]
: `${targetStatuses.length} statuses`;
const operationText =
operation === "set"
? "updated"
: operation === "add"
? "added to"
: operation === "remove"
? "removed from"
: "toggled on";
new Notice(
`${statusText} ${operationText} ${targetFiles.length} files`,
);
}
},
[app, settings, getFileStatuses],
);
const handleStatusChange = useCallback(
async (options: {
files: TFile | TFile[];
statuses: string | string[];
isMultipleSelection?: boolean;
allowMultipleStatuses?: boolean;
operation?: "set" | "add" | "remove" | "toggle";
showNotice?: boolean;
afterChange?: (updatedStatuses: string[]) => void;
}): Promise<void> => {
const {
files,
statuses,
isMultipleSelection = false,
allowMultipleStatuses = settings.useMultipleStatuses,
operation: explicitOperation,
showNotice = isMultipleSelection,
} = options;
const targetFiles = Array.isArray(files) ? files : [files];
const targetStatuses = Array.isArray(statuses)
? statuses
: [statuses];
let operation: "set" | "add" | "remove" | "toggle";
if (explicitOperation) {
operation = explicitOperation;
} else if (isMultipleSelection) {
const firstStatus = targetStatuses[0];
const filesWithStatus = targetFiles.filter((file) =>
getFileStatuses(file).includes(firstStatus),
);
operation =
filesWithStatus.length > targetFiles.length / 2
? "remove"
: "add";
} else {
if (allowMultipleStatuses) {
operation = "toggle";
} else {
operation = "set";
}
}
await modifyNoteStatus({
files: targetFiles,
statuses: targetStatuses,
operation,
showNotice,
});
},
[settings, getFileStatuses, modifyNoteStatus],
);
return useMemo(
() => ({
getAllStatuses,
getTemplateStatuses,
getFileStatuses,
getStatusIcon,
insertStatusMetadataInEditor,
getMarkdownFiles,
groupFilesByStatus,
handleStatusChange,
updateAllStatuses,
}),
[
getAllStatuses,
getTemplateStatuses,
getFileStatuses,
getStatusIcon,
insertStatusMetadataInEditor,
getMarkdownFiles,
groupFilesByStatus,
handleStatusChange,
updateAllStatuses,
],
);
};

View file

@ -1,88 +0,0 @@
import { useEffect, useCallback, useRef } from "react";
import { NoteStatusSettings } from "../models/types";
import { PREDEFINED_TEMPLATES } from "../constants/status-templates";
interface UseStyleServiceOptions {
settings: NoteStatusSettings;
}
export const useStyleService = ({ settings }: UseStyleServiceOptions) => {
const dynamicStyleElRef = useRef<HTMLStyleElement | null>(null);
const getAllStatusColors = useCallback((): Record<string, string> => {
const colors = { ...settings.statusColors };
if (settings.useCustomStatusesOnly) return colors;
for (const templateId of settings.enabledTemplates) {
const template = PREDEFINED_TEMPLATES.find(
(t) => t.id === templateId,
);
if (!template) continue;
for (const status of template.statuses) {
if (status.color && !colors[status.name]) {
colors[status.name] = status.color;
}
}
}
return colors;
}, [settings]);
const generateColorCssRules = useCallback(
(colors: Record<string, string>): string => {
let css = "";
for (const [status, color] of Object.entries(colors)) {
css += `
.status-${status} {
color: ${color} !important;
}
.note-status-bar .note-status-${status},
.nav-file-title .note-status-${status} {
color: ${color} !important;
}
`;
}
return css;
},
[],
);
const updateDynamicStyles = useCallback((): void => {
if (!dynamicStyleElRef.current) {
dynamicStyleElRef.current = document.createElement("style");
document.head.appendChild(dynamicStyleElRef.current);
}
const allColors = getAllStatusColors();
const cssRules = generateColorCssRules(allColors);
dynamicStyleElRef.current.textContent = cssRules;
}, [getAllStatusColors, generateColorCssRules]);
const unload = useCallback((): void => {
if (dynamicStyleElRef.current) {
dynamicStyleElRef.current.remove();
dynamicStyleElRef.current = null;
}
}, []);
useEffect(() => {
updateDynamicStyles();
return () => {
unload();
};
}, [updateDynamicStyles, unload]);
useEffect(() => {
updateDynamicStyles();
}, [settings, updateDynamicStyles]);
return {
updateDynamicStyles,
unload,
};
};

View file

@ -1,323 +0,0 @@
// integrations/commands/command-integration.ts
import { App, Editor, MarkdownView, Notice, TFile } from "obsidian";
import { NoteStatusSettings } from "../../models/types";
import { StatusService } from "services/status-service";
import { StatusDropdown } from "components/status-dropdown";
import { StatusPaneViewController } from "views/status-pane-view";
import NoteStatus from "main";
export class CommandIntegration {
private app: App;
private plugin: NoteStatus;
private settings: NoteStatusSettings;
private statusService: StatusService;
private statusDropdown: StatusDropdown;
constructor(
app: App,
plugin: NoteStatus,
settings: NoteStatusSettings,
statusService: StatusService,
statusDropdown: StatusDropdown,
) {
this.app = app;
this.plugin = plugin;
this.settings = settings;
this.statusService = statusService;
this.statusDropdown = statusDropdown;
}
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
// Re-register commands when settings change
this.unload();
this.registerCommands();
}
public registerCommands(): void {
// Open status pane
this.plugin.addCommand({
id: "open-status-pane",
name: "Open status pane",
callback: () => StatusPaneViewController.open(this.app),
});
// Change status of current note
this.plugin.addCommand({
id: "change-status",
name: "Change status of current note",
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
this.statusDropdown.openStatusDropdown({ files: [file] });
}
return true;
},
});
// Add status to current note
this.plugin.addCommand({
id: "add-status",
name: "Add status to current note",
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
this.statusDropdown.openStatusDropdown({
files: [file],
mode: this.settings.useMultipleStatuses
? "add"
: "replace",
});
}
return true;
},
});
// Insert status metadata
this.plugin.addCommand({
id: "insert-status-metadata",
name: "Insert status metadata",
editorCheckCallback: (
checking: boolean,
editor: Editor,
view: MarkdownView,
) => {
if (!view.file) return false;
const statuses = this.statusService.getFileStatuses(view.file);
const hasNoStatus =
statuses.length === 1 && statuses[0] === "unknown";
if (!checking && hasNoStatus) {
this.statusService.insertStatusMetadataInEditor(editor);
new Notice("Status metadata inserted");
}
return hasNoStatus;
},
});
// Cycle through statuses
this.plugin.addCommand({
id: "cycle-status",
name: "Cycle to next status",
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
this.cycleStatus(file);
}
return true;
},
});
// Register dynamic quick status commands
this.registerQuickStatusCommands();
// Clear status
this.plugin.addCommand({
id: "clear-status",
name: "Clear status (set to unknown)",
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
this.statusService.handleStatusChange({
files: file,
statuses: "unknown",
operation: "set",
});
new Notice("Status cleared");
}
return true;
},
});
// Copy status from current note
this.plugin.addCommand({
id: "copy-status",
name: "Copy status from current note",
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
const statuses = this.statusService.getFileStatuses(file);
this.app.clipboard = statuses;
new Notice(`Copied status: ${statuses.join(", ")}`);
}
return true;
},
});
// Paste status to current note
this.plugin.addCommand({
id: "paste-status",
name: "Paste status to current note",
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
const clipboard = this.app.clipboard;
if (!file || !clipboard || !Array.isArray(clipboard))
return false;
if (!checking) {
this.statusService.handleStatusChange({
files: file,
statuses: clipboard,
operation: "set",
});
new Notice(`Pasted status: ${clipboard.join(", ")}`);
}
return true;
},
});
// Toggle multiple statuses mode
this.plugin.addCommand({
id: "toggle-multiple-statuses",
name: "Toggle multiple statuses mode",
callback: () => {
this.settings.useMultipleStatuses =
!this.settings.useMultipleStatuses;
this.plugin.saveSettings();
new Notice(
`Multiple statuses mode ${this.settings.useMultipleStatuses ? "enabled" : "disabled"}`,
);
},
});
// Search notes by status
this.plugin.addCommand({
id: "search-by-status",
name: "Search notes by current status",
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
const statuses = this.statusService.getFileStatuses(file);
const query = `[${this.settings.tagPrefix}:"${statuses[0]}"]`;
this.app.internalPlugins
.getPluginById("global-search")
?.instance.openGlobalSearch(query);
}
return true;
},
});
}
/**
* Register quick status commands based on settings
*/
private registerQuickStatusCommands(): void {
const quickCommands = this.settings.quickStatusCommands || [];
const allStatuses = this.statusService.getAllStatuses();
quickCommands.forEach((statusName) => {
const status = allStatuses.find((s) => s.name === statusName);
if (!status) return;
this.plugin.addCommand({
id: `set-status-${statusName}`,
name: `Set status to ${statusName}`,
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
this.statusService.handleStatusChange({
files: file,
statuses: statusName,
operation: "set",
});
new Notice(`Status set to ${statusName}`);
}
return true;
},
});
// Add toggle command for multiple status mode
if (this.settings.useMultipleStatuses) {
this.plugin.addCommand({
id: `toggle-status-${statusName}`,
name: `Toggle status ${statusName}`,
checkCallback: (checking: boolean) => {
const file = this.app.workspace.getActiveFile();
if (!file) return false;
if (!checking) {
this.statusService.handleStatusChange({
files: file,
statuses: statusName,
operation: "toggle",
});
const currentStatuses =
this.statusService.getFileStatuses(file);
const hasStatus =
currentStatuses.includes(statusName);
new Notice(
`Status ${statusName} ${hasStatus ? "added" : "removed"}`,
);
}
return true;
},
});
}
});
}
private cycleStatus(file: TFile): void {
const allStatuses = this.statusService
.getAllStatuses()
.filter((s) => s.name !== "unknown")
.map((s) => s.name);
if (allStatuses.length === 0) {
new Notice("No statuses available");
return;
}
const currentStatuses = this.statusService.getFileStatuses(file);
const currentStatus = currentStatuses[0];
let nextIndex = 0;
if (currentStatus !== "unknown") {
const currentIndex = allStatuses.indexOf(currentStatus);
nextIndex = (currentIndex + 1) % allStatuses.length;
}
this.statusService.handleStatusChange({
files: file,
statuses: allStatuses[nextIndex],
operation: "set",
});
new Notice(`Status changed to ${allStatuses[nextIndex]}`);
}
public unload(): void {
// Remove existing commands
this.removeQuickStatusCommands();
}
/**
* Remove all quick status commands
*/
private removeQuickStatusCommands(): void {
const allStatuses = this.statusService.getAllStatuses();
allStatuses.forEach((status) => {
this.app.commands.removeCommand(
`note-status:set-status-${status.name}`,
);
this.app.commands.removeCommand(
`note-status:toggle-status-${status.name}`,
);
});
}
}

View file

@ -0,0 +1,36 @@
import { Plugin } from "obsidian";
import { CommandsService } from "../../core/commandsService";
export class CommandsIntegration {
private static instance: CommandsIntegration | null = null;
private plugin: Plugin;
private commandsService: CommandsService;
constructor(plugin: Plugin) {
if (CommandsIntegration.instance) {
throw new Error(
"CommandsIntegration instance already created. Use getInstance() instead.",
);
}
this.plugin = plugin;
this.commandsService = new CommandsService(plugin);
CommandsIntegration.instance = this;
}
static getInstance(): CommandsIntegration | null {
return CommandsIntegration.instance;
}
async integrate(): Promise<void> {
// Register all commands from the service
this.commandsService.registerAllCommands();
}
destroy(): void {
if (this.commandsService) {
this.commandsService.unload();
this.commandsService.destroy();
}
CommandsIntegration.instance = null;
}
}

View file

@ -0,0 +1,127 @@
import eventBus from "core/eventBus";
import {
Menu,
Notice,
Plugin,
TAbstractFile,
TFile,
WorkspaceLeaf,
} from "obsidian";
import {
MultipleNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
export class ContextMenuIntegration {
private static instance: ContextMenuIntegration | null = null;
private plugin: Plugin;
private currentLeaf: WorkspaceLeaf | null = null;
private noteStatusService:
| NoteStatusService
| MultipleNoteStatusService
| null = null;
constructor(plugin: Plugin) {
if (ContextMenuIntegration.instance) {
throw new Error("The context menu instance is already created");
}
this.plugin = plugin;
ContextMenuIntegration.instance = this;
}
async integrate() {
this.plugin.registerEvent(
this.plugin.app.workspace.on(
"files-menu",
(
menu: Menu,
files: TAbstractFile[],
source: string,
leaf?: WorkspaceLeaf,
) => {
menu.addItem((item) => {
item.setTitle("Print file path 👈")
.setIcon("document")
.onClick(async () => {
const tFiles = files.filter(
(f): f is TFile => f instanceof TFile,
);
if (tFiles.length) {
this.openMultipleFilesStatusesModal(tFiles);
} else {
new Notice(
"The selected files are not valid to add status, just .md files can have status in this plugin version",
);
}
});
});
},
),
);
this.plugin.registerEvent(
this.plugin.app.workspace.on("file-menu", (menu, file, f, f2) => {
menu.addItem((item) => {
item.setTitle("Print file path 👈")
.setIcon("document")
.onClick(async () => {
if (file instanceof TFile) {
this.openSingleFileStatusesModal(file);
} else {
new Notice(
"The selected file is not valid to add status, just .md files can have status in this plugin version",
);
}
});
});
}),
);
this.plugin.registerEvent(
this.plugin.app.workspace.on(
"editor-menu",
(menu, editor, view) => {
menu.addItem((item) => {
item.setTitle("Print file path 👈")
.setIcon("document")
.onClick(async () => {
if (view.file) {
if (view.file instanceof TFile) {
this.openSingleFileStatusesModal(
view.file,
);
} else {
new Notice(
"The selected file is not valid to add status, just .md files can have status in this plugin version",
);
}
}
});
});
},
),
);
}
private openMultipleFilesStatusesModal(tFiles: TFile[]) {
const multipleStatusService = new MultipleNoteStatusService(tFiles);
multipleStatusService.populateStatuses();
eventBus.publish("triggered-open-modal", {
statusService: multipleStatusService,
});
}
private openSingleFileStatusesModal(tFile: TFile) {
const noteStatusService = new NoteStatusService(tFile);
noteStatusService.populateStatuses();
eventBus.publish("triggered-open-modal", {
statusService: noteStatusService,
});
}
destroy() {
this.currentLeaf = null;
this.noteStatusService = null;
ContextMenuIntegration.instance = null;
}
}
export default ContextMenuIntegration;

View file

@ -1,108 +0,0 @@
import { App, Menu, TFile } from "obsidian";
import { NoteStatusSettings } from "models/types";
import { StatusService } from "services/status-service";
import { ExplorerIntegration } from "integrations/explorer/explorer-integration";
import { StatusContextMenu } from "integrations/context-menu/status-context-menu";
/**
* Gestiona la integración de menús contextuales con el explorador de archivos
*/
export class FileContextMenuIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private explorerIntegration: ExplorerIntegration;
private statusContextMenu: StatusContextMenu;
private fileMenuEventRef: (menu: Menu, file: TFile, source: string) => void;
private filesMenuEventRef: (menu: Menu, files: TFile[]) => void;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
explorerIntegration: ExplorerIntegration,
statusContextMenu: StatusContextMenu,
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.explorerIntegration = explorerIntegration;
this.statusContextMenu = statusContextMenu;
}
/**
* Actualiza la configuración
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
/**
* Registra los eventos del menú contextual de archivos
*/
public registerFileContextMenuEvents(): void {
this.fileMenuEventRef = (menu, file, source) => {
if (
source === "file-explorer-context-menu" &&
file instanceof TFile &&
file.extension === "md"
) {
this.addStatusChangeMenu(menu, file);
}
};
this.filesMenuEventRef = (menu, files) => {
const mdFiles = files.filter(
(file) => file instanceof TFile && file.extension === "md",
) as TFile[];
if (mdFiles.length > 0) {
this.addBatchStatusChangeMenu(menu, mdFiles);
}
};
this.app.workspace.on("file-menu", this.fileMenuEventRef);
this.app.workspace.on("files-menu", this.filesMenuEventRef);
}
/**
* Añade opción de cambio de estado al menú contextual de un archivo
*/
private addStatusChangeMenu(menu: Menu, file: TFile): void {
this.statusContextMenu.addStatusMenuItemToSingleFile(
menu,
file,
(file) => {
const selectedFiles =
this.explorerIntegration.getSelectedFiles();
if (selectedFiles.length > 1) {
this.statusContextMenu.showForFiles(selectedFiles);
} else {
this.statusContextMenu.showForFiles([file]);
}
},
);
}
/**
* Añade opción de cambio de estado para múltiples archivos
*/
private addBatchStatusChangeMenu(menu: Menu, files: TFile[]): void {
this.statusContextMenu.addStatusMenuItemToBatch(
menu,
files,
(files) => {
this.statusContextMenu.showForFiles(files);
},
);
}
public unload(): void {
if (this.fileMenuEventRef) {
this.app.workspace.off("file-menu", this.fileMenuEventRef);
}
if (this.filesMenuEventRef) {
this.app.workspace.off("files-menu", this.filesMenuEventRef);
}
}
}

View file

@ -1,146 +0,0 @@
import { App, Menu, TFile } from "obsidian";
import { NoteStatusSettings } from "models/types";
import { StatusService } from "services/status-service";
import { StatusDropdown } from "components/status-dropdown";
import { ExplorerIntegration } from "integrations/explorer/explorer-integration";
/**
* Gestiona los menús contextuales para cambios de estado
*/
export class StatusContextMenu {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private statusDropdown: StatusDropdown;
private explorerIntegration: ExplorerIntegration;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
statusDropdown: StatusDropdown,
explorerIntegration: ExplorerIntegration,
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.statusDropdown = statusDropdown;
this.explorerIntegration = explorerIntegration;
}
/**
* Actualiza la configuración
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
/**
* Añade ítem de menú para cambiar estado de un archivo
*/
public addStatusMenuItemToSingleFile(
menu: Menu,
file: TFile,
onClick: (file: TFile) => void,
): void {
menu.addItem((item) =>
item
.setTitle("Change status")
.setIcon("tag")
.onClick(() => onClick(file)),
);
}
/**
* Añade ítem de menú para cambiar estado de múltiples archivos
*/
public addStatusMenuItemToBatch(
menu: Menu,
files: TFile[],
onClick: (files: TFile[]) => void,
): void {
menu.addItem((item) =>
item
.setTitle("Change status")
.setIcon("tag")
.onClick(() => onClick(files)),
);
}
/**
* Muestra el menú contextual para cambiar estado de uno o más archivos
* @param files Archivos a los que cambiar el estado
* @param position Posición opcional para mostrar el menú
*/
public showForFiles(
files: TFile[],
position?: { x: number; y: number },
): void {
if (files.length === 0) return;
if (files.length === 1) {
this.showForSingleFile(files[0], position);
} else {
this.showForMultipleFiles(files, position);
}
}
/**
* Muestra el menú contextual para un solo archivo
* @param file Archivo al que cambiar el estado
* @param position Posición opcional para mostrar el menú
*/
private showForSingleFile(
file: TFile,
position?: { x: number; y: number },
): void {
if (!(file instanceof TFile) || file.extension !== "md") return;
this.statusDropdown.openStatusDropdown({
position,
files: [file],
});
}
/**
* Muestra el menú contextual para múltiples archivos
* @param files Archivos a los que cambiar el estado
* @param position Posición opcional para mostrar el menú
*/
private showForMultipleFiles(
files: TFile[],
position?: { x: number; y: number },
): void {
const menu = new Menu();
// Elemento de información (deshabilitado)
menu.addItem((item) => {
item.setTitle(`Update ${files.length} files`).setDisabled(true);
return item;
});
// Opción para gestionar estados
menu.addItem((item) =>
item
.setTitle("Manage statuses...")
.setIcon("tag")
.onClick(() => {
this.statusDropdown.openStatusDropdown({
position,
files,
});
}),
);
// Mostrar el menú en la posición adecuada
if (position) {
menu.showAtPosition(position);
} else {
// Use a centered position
menu.showAtPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 3,
});
}
}
}

View file

@ -1,92 +0,0 @@
import { App, Editor, MarkdownView, Menu } from "obsidian";
import { NoteStatusSettings } from "../../models/types";
import { StatusService } from "services/status-service";
import { StatusDropdown } from "components/status-dropdown";
export class EditorIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private statusDropdown: StatusDropdown;
private editorMenuRef: (
menu: Menu,
editor: Editor,
view: MarkdownView,
) => void;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
statusDropdown: StatusDropdown,
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.statusDropdown = statusDropdown;
}
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
public registerEditorMenus(): void {
this.editorMenuRef = (
menu: Menu,
editor: Editor,
view: MarkdownView,
) => {
if (view.file) {
this.addStatusMenuItems(menu, editor, view);
}
};
this.app.workspace.on("editor-menu", this.editorMenuRef);
}
private addStatusMenuItems(
menu: Menu,
editor: Editor,
view: MarkdownView,
): void {
menu.addItem((item) =>
item
.setTitle("Change note status")
.setIcon("tag")
.onClick(() => {
if (view.file) {
this.statusDropdown.openStatusDropdown({
files: [view.file],
editor,
view,
});
}
}),
);
// Only show insert metadata if it doesn't exist
if (view.file) {
const statuses = this.statusService.getFileStatuses(view.file);
if (statuses.length === 1 && statuses[0] === "unknown") {
menu.addItem((item) =>
item
.setTitle("Insert status metadata")
.setIcon("plus-circle")
.onClick(() => {
this.insertStatusMetadata(editor);
}),
);
}
}
}
public insertStatusMetadata(editor: Editor): void {
this.statusService.insertStatusMetadataInEditor(editor);
}
public unload(): void {
if (this.editorMenuRef) {
this.app.workspace.off("editor-menu", this.editorMenuRef);
}
}
}

View file

@ -1,2 +0,0 @@
export { EditorIntegration } from "./editor-integration";
export { ToolbarIntegration } from "./toolbar-integration";

View file

@ -1,135 +0,0 @@
import { App, MarkdownView } from "obsidian";
import { NoteStatusSettings } from "../../models/types";
import { StatusService } from "../../services/status-service";
import { StatusDropdown } from "../../components/status-dropdown";
import { ToolbarButtonManager } from "components/ToolbarButton";
/**
* Gestiona la integración con la barra de herramientas del editor
*/
export class ToolbarIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private statusDropdown: StatusDropdown;
private buttonView: ToolbarButtonManager;
private buttonElement: HTMLElement | null = null;
private currentLeafId: string | null = null;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
statusDropdown: StatusDropdown,
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.statusDropdown = statusDropdown;
this.buttonView = new ToolbarButtonManager(settings, statusService);
}
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.buttonView.updateSettings(settings);
this.updateStatusDisplay([]);
}
public addToolbarButtonToActiveLeaf(statuses?: string[]): void {
const activeLeaf = this.app.workspace.activeLeaf;
if (!activeLeaf?.view || !(activeLeaf.view instanceof MarkdownView))
return;
const leafId = activeLeaf.id || activeLeaf.view.containerEl.id;
// Only recreate button if we're on a different leaf or button doesn't exist
if (
this.currentLeafId !== leafId ||
!this.buttonElement ||
!this.isButtonInDOM()
) {
this.recreateButton(activeLeaf.view, leafId);
}
this.updateButtonDisplay(statuses);
}
private recreateButton(view: MarkdownView, leafId: string): void {
const toolbarContainer = view.containerEl.querySelector(
".view-header .view-actions",
);
if (!toolbarContainer) return;
// Remove old button if it exists
this.removeToolbarButton();
// Create new button
this.buttonElement = this.buttonView.createElement();
this.buttonElement.addEventListener(
"click",
this.handleButtonClick.bind(this),
);
if (toolbarContainer.firstChild) {
toolbarContainer.insertBefore(
this.buttonElement,
toolbarContainer.firstChild,
);
} else {
toolbarContainer.appendChild(this.buttonElement);
}
this.currentLeafId = leafId;
}
private isButtonInDOM(): boolean {
return this.buttonElement?.isConnected === true;
}
private removeToolbarButton(): void {
this.app.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof MarkdownView) {
const buttons = leaf.view.containerEl.querySelectorAll(
".note-status-toolbar-button",
);
buttons.forEach((button) => button.remove());
}
});
this.buttonElement = null;
this.currentLeafId = null;
}
private updateButtonDisplay(overrideStatuses?: string[]): void {
if (!this.buttonElement) return;
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
const statuses = overrideStatuses?.length
? overrideStatuses
: this.statusService.getFileStatuses(activeFile);
this.buttonView.updateDisplay(statuses);
}
private handleButtonClick(e: MouseEvent): void {
e.stopPropagation();
e.preventDefault();
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
this.statusDropdown.openStatusDropdown({
target: this.buttonElement || undefined,
files: [activeFile],
});
}
public updateStatusDisplay(statuses: string[]): void {
this.updateButtonDisplay(statuses);
}
public unload(): void {
// this.buttonView.destroy();
this.removeToolbarButton();
}
}

View file

@ -1,432 +0,0 @@
import { App, TFile, setTooltip, debounce } from "obsidian";
import { NoteStatusSettings, FileExplorerView } from "../../models/types";
import { StatusService } from "services/status-service";
/**
* Manages the logic for file explorer status integration
*/
export class ExplorerIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private ui: ExplorerIntegrationUI;
private iconUpdateQueue = new Set<string>();
private isProcessingQueue = false;
private debouncedUpdateAll: ReturnType<typeof debounce>;
private fileItemsWasUndefined = false;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.ui = new ExplorerIntegrationUI(
app,
settings,
statusService,
() => {
this.fileItemsWasUndefined = true;
},
);
this.debouncedUpdateAll = debounce(
this.processUpdateQueue.bind(this),
100,
true,
);
// Listen for layout changes to detect when file explorer becomes available
this.app.workspace.on("layout-change", () => {
this.checkFileExplorerAvailability();
});
}
/**
* Check if file explorer became available after being unavailable
*/
private checkFileExplorerAvailability(): void {
if (!this.settings.showStatusIconsInExplorer) return;
const fileExplorer = this.ui.findFileExplorerView();
if (fileExplorer?.fileItems && this.fileItemsWasUndefined) {
this.fileItemsWasUndefined = false;
setTimeout(() => this.updateAllFileExplorerIcons(), 100);
}
}
/**
* Updates settings and refreshes UI if necessary
*/
public updateSettings(settings: NoteStatusSettings): void {
const shouldRefreshIcons =
this.settings.showStatusIconsInExplorer !==
settings.showStatusIconsInExplorer ||
this.settings.hideUnknownStatusInExplorer !==
settings.hideUnknownStatusInExplorer;
this.settings = { ...settings };
this.ui.updateSettings(settings);
if (shouldRefreshIcons) {
this.ui.removeAllFileExplorerIcons();
if (settings.showStatusIconsInExplorer) {
setTimeout(() => this.updateAllFileExplorerIcons(), 50);
}
} else if (settings.showStatusIconsInExplorer) {
this.updateAllFileExplorerIcons();
}
}
/**
* Updates icons for a specific file
*/
public updateFileExplorerIcons(file: TFile): void {
if (
!file ||
!this.settings.showStatusIconsInExplorer ||
file.extension !== "md"
)
return;
const activeFile = this.app.workspace.getActiveFile();
if (activeFile?.path === file.path) {
this.ui.updateSingleFileIconDirectly(file, this.statusService);
}
this.queueFileUpdate(file);
}
/**
* Updates all icons in the explorer
*/
public updateAllFileExplorerIcons(): void {
if (!this.settings.showStatusIconsInExplorer) {
this.ui.removeAllFileExplorerIcons();
return;
}
this.processFilesInBatches();
}
/**
* Gets selected files from the explorer
*/
public getSelectedFiles(): TFile[] {
return this.ui.getSelectedFiles();
}
/**
* Queue a file for icon update
*/
private queueFileUpdate(file: TFile): void {
if (!this.settings.showStatusIconsInExplorer || file.extension !== "md")
return;
this.iconUpdateQueue.add(file.path);
this.debouncedUpdateAll();
}
/**
* Process the update queue
*/
private async processUpdateQueue(): Promise<void> {
if (this.isProcessingQueue || this.iconUpdateQueue.size === 0) return;
this.isProcessingQueue = true;
try {
const fileExplorerView = this.ui.findFileExplorerView();
if (!fileExplorerView || !fileExplorerView.fileItems) {
this.fileItemsWasUndefined = true;
setTimeout(() => this.debouncedUpdateAll(), 200);
return;
}
const allPaths = Array.from(this.iconUpdateQueue);
const batchSize = 50;
for (let i = 0; i < allPaths.length; i += batchSize) {
await this.processBatch(
allPaths.slice(i, i + batchSize),
fileExplorerView,
);
if (i + batchSize < allPaths.length) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
} catch (error) {
console.error(
"Note Status: Error processing file update queue",
error,
);
} finally {
this.isProcessingQueue = false;
if (this.iconUpdateQueue.size > 0) {
this.debouncedUpdateAll();
}
}
}
/**
* Process a batch of files
*/
private async processBatch(
paths: string[],
fileExplorerView: FileExplorerView,
): Promise<void> {
for (const path of paths) {
const file = this.app.vault.getFileByPath(path);
if (file instanceof TFile) {
this.ui.updateSingleFileIcon(
file,
fileExplorerView,
this.statusService,
);
}
this.iconUpdateQueue.delete(path);
}
}
/**
* Process files in batches
*/
private async processFilesInBatches(): Promise<void> {
const files = this.app.vault.getMarkdownFiles();
const batchSize = 100;
for (let i = 0; i < files.length; i += batchSize) {
files
.slice(i, i + batchSize)
.forEach((file) => this.queueFileUpdate(file));
if (i + batchSize < files.length) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
}
/**
* Cleanup when unloading the plugin
*/
public unload(): void {
this.ui.removeAllFileExplorerIcons();
this.debouncedUpdateAll.cancel();
}
}
/**
* Manages UI operations for file explorer icons
*/
export class ExplorerIntegrationUI {
private app: App;
private settings: NoteStatusSettings;
private onFileItemsUndefined: () => void;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
onFileItemsUndefined: () => void,
) {
this.app = app;
this.settings = settings;
this.onFileItemsUndefined = onFileItemsUndefined;
}
/**
* Updates the settings
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
/**
* Finds the file explorer view
*/
public findFileExplorerView(): FileExplorerView | null {
const leaf = this.app.workspace.getLeavesOfType("file-explorer")[0];
if (leaf?.view) return leaf.view as FileExplorerView;
for (const leaf of this.app.workspace.getLeavesOfType("")) {
if (leaf.view && "fileItems" in leaf.view) {
return leaf.view as FileExplorerView;
}
}
return null;
}
/**
* Updates a single file icon
*/
public updateSingleFileIcon(
file: TFile,
fileExplorerView: FileExplorerView,
statusService: StatusService,
): void {
if (!this.settings.showStatusIconsInExplorer || file.extension !== "md")
return;
try {
// Check if fileItems is initialized
if (!fileExplorerView.fileItems) {
this.onFileItemsUndefined();
return;
}
const fileItem = fileExplorerView.fileItems[file.path];
if (!fileItem) return;
const titleEl = fileItem.titleEl || fileItem.selfEl;
if (!titleEl) return;
const statuses = statusService.getFileStatuses(file);
this.removeExistingIcons(titleEl);
if (this.shouldSkipIcon(statuses)) return;
this.addStatusIcons(titleEl, statuses, statusService);
} catch (error) {
console.error(
`Note Status: Error updating icon for ${file.path}`,
error,
);
}
}
/**
* Updates the icon for the active file directly
*/
public updateSingleFileIconDirectly(
file: TFile,
statusService: StatusService,
): void {
const fileExplorer = this.findFileExplorerView();
if (fileExplorer) {
this.updateSingleFileIcon(file, fileExplorer, statusService);
}
}
/**
* Removes all file explorer icons
*/
public removeAllFileExplorerIcons(): void {
const fileExplorer = this.findFileExplorerView();
if (!fileExplorer?.fileItems) return;
Object.values(fileExplorer.fileItems).forEach((fileItem) => {
const titleEl = fileItem.titleEl || fileItem.selfEl;
if (titleEl) this.removeExistingIcons(titleEl);
});
}
/**
* Gets the currently selected files
*/
public getSelectedFiles(): TFile[] {
const fileExplorer = this.findFileExplorerView();
if (!fileExplorer?.fileItems) return [];
return Object.values(fileExplorer.fileItems)
.filter(
(item) =>
item.el?.classList.contains("is-selected") &&
item.file instanceof TFile &&
item.file.extension === "md",
)
.map((item) => item.file as TFile);
}
/**
* Checks if the icon should be skipped based on status
*/
private shouldSkipIcon(statuses: string[]): boolean {
return (
this.settings.hideUnknownStatusInExplorer &&
statuses.length === 1 &&
statuses[0] === "unknown"
);
}
/**
* Removes existing status icons
*/
private removeExistingIcons(element: HTMLElement): void {
const iconSelectors = ".note-status-icon, .note-status-icon-container";
element.querySelectorAll(iconSelectors).forEach((icon) => {
if (
icon.classList.contains("note-status-icon") ||
icon.classList.contains("note-status-icon-container")
) {
icon.remove();
}
});
}
/**
* Adds status icons to an element
*/
private addStatusIcons(
titleEl: HTMLElement,
statuses: string[],
statusService: StatusService,
): void {
const iconContainer = document.createElement("span");
iconContainer.className = "note-status-icon-container";
if (
this.settings.useMultipleStatuses &&
statuses.length > 0 &&
statuses[0] !== "unknown"
) {
statuses.forEach((status) =>
this.addSingleStatusIcon(iconContainer, status, statusService),
);
} else {
const primaryStatus = statuses[0] || "unknown";
if (
primaryStatus !== "unknown" ||
!this.settings.hideUnknownStatusInExplorer
) {
this.addSingleStatusIcon(
iconContainer,
primaryStatus,
statusService,
);
}
}
if (iconContainer.childElementCount > 0) {
titleEl.appendChild(iconContainer);
}
}
/**
* Adds a single status icon
*/
private addSingleStatusIcon(
container: HTMLElement,
status: string,
statusService: StatusService,
): void {
const iconEl = document.createElement("span");
iconEl.className = `note-status-icon nav-file-tag status-${status}`;
iconEl.textContent = statusService.getStatusIcon(status);
const statusObj = statusService
.getAllStatuses()
.find((s) => s.name === status);
const tooltipValue = statusObj?.description
? `${status} - ${statusObj.description}`
: status;
setTooltip(iconEl, tooltipValue);
container.appendChild(iconEl);
}
}

View file

@ -1 +0,0 @@
export { ExplorerIntegration } from "./explorer-integration";

View file

@ -0,0 +1,207 @@
import { FileExplorerIcon } from "@/components/FileExplorer/FileExplorerIcon";
import eventBus from "@/core/eventBus";
import {
LazyElementObserver,
IElementProcessor,
} from "@/core/lazyElementObserver";
import { NoteStatusService } from "@/core/noteStatusService";
import { GroupedStatuses } from "@/types/noteStatus";
import { Plugin, TFile, View } from "obsidian";
import { createRoot } from "react-dom/client";
import { StatusesInfoPopup } from "../popups/statusesInfoPopupIntegration";
export class FileExplorerIntegration implements IElementProcessor {
private plugin: Plugin;
private observerService: LazyElementObserver;
private readonly ICON_CLASS = "custom-icon";
private readonly EVENT_SUBSCRIPTION_ID =
"file-explorer-integration-subscription1";
private readonly FILE_EXPLORER_TYPE = "file-explorer";
private readonly CONTAINER_SELECTOR = ".nav-files-container";
constructor(plugin: Plugin) {
this.plugin = plugin;
this.observerService = new LazyElementObserver(this);
}
/**
* Initializes file explorer integration
*/
async integrate(): Promise<void> {
this.setupWorkspaceIntegration();
eventBus.subscribe(
"frontmatter-manually-changed",
({ file }) => {
const element = this.findFileElement(file.path);
if (!element) return;
this.refreshElementIcon(element, file.path);
},
this.EVENT_SUBSCRIPTION_ID,
);
}
private getFileNoteStatusService(
dataPath: string,
): NoteStatusService | null {
if (!dataPath.endsWith(".md")) {
return null;
}
const file = this.plugin.app.vault.getAbstractFileByPath(
dataPath,
) as TFile;
if (!file) {
return null;
}
const noteStatusService = new NoteStatusService(file);
noteStatusService.populateStatuses();
return noteStatusService;
}
/**
* Processes visible file explorer elements (IElementProcessor implementation)
*/
processElement(element: HTMLElement, dataPath?: string | null): void {
if (!dataPath) {
dataPath = element.getAttribute("data-path");
}
if (dataPath) {
const textEl = element.querySelector(
".nav-file-title-content, .tree-item-inner, .nav-folder-title-content",
);
if (!textEl) {
return;
}
const noteStatusService = this.getFileNoteStatusService(dataPath);
this.render(textEl, noteStatusService?.statuses ?? {});
}
}
render(element: Element, statuses: GroupedStatuses): void {
// Remove existing icon
const existingIcon = element.querySelector(`.${this.ICON_CLASS}`);
if (existingIcon) {
existingIcon.remove();
}
const icon = createSpan({ cls: this.ICON_CLASS });
const root = createRoot(icon);
root.render(
<FileExplorerIcon
statuses={statuses}
onMouseEnter={(s) => this.openModalInfo(s)}
onMouseLeave={this.closeModalInfo}
/>,
);
element.prepend(icon);
}
private openModalInfo(statuses: GroupedStatuses) {
if (!this.plugin) {
return;
}
StatusesInfoPopup.open(statuses);
}
private closeModalInfo() {
StatusesInfoPopup.close();
}
/**
* Cleanup integration and unsubscribe from events
*/
destroy(): void {
this.observerService.cleanup();
eventBus.unsubscribe(
"frontmatter-manually-changed",
this.EVENT_SUBSCRIPTION_ID,
);
}
/**
* Sets up workspace-level integration
*/
private setupWorkspaceIntegration(): void {
this.plugin.app.workspace.onLayoutReady(() => {
this.patchFileExplorer();
this.registerActiveLeafChangeHandler();
});
}
/**
* Registers handler for active leaf changes
*/
private registerActiveLeafChangeHandler(): void {
this.plugin.registerEvent(
this.plugin.app.workspace.on("active-leaf-change", () => {
this.patchFileExplorer();
}),
);
}
/**
* Finds file element in the file explorer
*/
private findFileElement(filePath: string): HTMLElement | null {
const fileExplorerView = this.getFileExplorerView();
if (!fileExplorerView) return null;
return fileExplorerView.containerEl.querySelector(
`[data-path="${filePath}"]`,
) as HTMLElement;
}
/**
* Refreshes icon for a specific element
*/
private refreshElementIcon(element: HTMLElement, dataPath: string): void {
//
// Remove existing icon
// this.iconRenderer.removeIcon(element);
// Mark for reprocessing
this.observerService.markElementForReprocessing(element);
this.processElement(element, dataPath);
//
// // Re-add icon with updated status
// this.iconRenderer.renderIcon(element, dataPath);
}
/**
* Patches the file explorer with observers
*/
private patchFileExplorer(): void {
const container = this.getFileExplorerContainer();
if (!container) return;
this.observerService.setupObservers(container);
}
/**
* Gets the file explorer view
*/
private getFileExplorerView(): View | null {
const leaves = this.plugin.app.workspace.getLeavesOfType(
this.FILE_EXPLORER_TYPE,
);
return leaves[0]?.view || null;
}
/**
* Gets the file explorer container element
*/
private getFileExplorerContainer(): Element | null {
const fileExplorerView = this.getFileExplorerView();
if (!fileExplorerView) return null;
return fileExplorerView.containerEl.querySelector(
this.CONTAINER_SELECTOR,
);
}
}

View file

@ -1 +0,0 @@
export { MetadataIntegration } from "./metadata-integration";

View file

@ -1,73 +0,0 @@
import { App, TFile } from "obsidian";
import { NoteStatusSettings } from "../../models/types";
import { ExplorerIntegration } from "../explorer/explorer-integration";
import { StatusService } from "services/status-service";
export class MetadataIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private explorerIntegration: ExplorerIntegration;
private metadataChangedRef: (file: TFile) => void;
private metadataResolvedRef: () => void;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
explorerIntegration: ExplorerIntegration,
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.explorerIntegration = explorerIntegration;
}
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
public registerMetadataEvents(): void {
this.metadataChangedRef = (file) => {
if (file instanceof TFile && file.extension === "md") {
this.handleMetadataChanged(file);
}
};
this.metadataResolvedRef = () => {
setTimeout(() => {
if (this.settings.showStatusIconsInExplorer) {
this.explorerIntegration.updateAllFileExplorerIcons();
}
}, 500);
};
this.app.metadataCache.on("changed", this.metadataChangedRef);
this.app.metadataCache.on("resolved", this.metadataResolvedRef);
}
private handleMetadataChanged(file: TFile): void {
if (this.settings.showStatusIconsInExplorer) {
this.explorerIntegration.updateFileExplorerIcons(file);
}
const activeFile = this.app.workspace.getActiveFile();
if (activeFile?.path === file.path) {
const statuses = this.statusService.getFileStatuses(file);
window.dispatchEvent(
new CustomEvent("note-status:status-changed", {
detail: { statuses, file: file.path },
}),
);
}
}
public unload(): void {
if (this.metadataChangedRef) {
this.app.metadataCache.off("changed", this.metadataChangedRef);
}
if (this.metadataResolvedRef) {
this.app.metadataCache.off("resolved", this.metadataResolvedRef);
}
}
}

View file

@ -0,0 +1,134 @@
import { App, Modal, Notice } from "obsidian";
import { createRoot, Root } from "react-dom/client";
import {
StatusModal as StatusModalComponent,
Props,
} from "@/components/StatusModal/StatusModal";
import SelectorService from "@/core/selectorService";
import {
MultipleNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
import eventBus from "@/core/eventBus";
export class StatusModalIntegration extends Modal {
private root: Root | null = null;
private static instance: StatusModalIntegration | null = null;
private selectorService: SelectorService;
constructor(app: App) {
super(app);
}
static open(
app: App,
noteStatusService: NoteStatusService | MultipleNoteStatusService,
) {
if (StatusModalIntegration.instance) {
throw new Error("Status Modal is already open");
}
StatusModalIntegration.instance = new StatusModalIntegration(app);
StatusModalIntegration.instance.selectorService = new SelectorService(
noteStatusService,
);
eventBus.subscribe(
"frontmatter-manually-changed",
() => {
// TODO: There are multiple calls to populateStatuses, in this case the noteStatusService is passed by reference, so redundant computations
// FIXME: Line 27
StatusModalIntegration.instance?.selectorService.noteStatusService.populateStatuses();
StatusModalIntegration.instance?.render();
},
"statusModalIntegrationSubscription1",
);
StatusModalIntegration.instance.open();
}
private onRemoveStatus: Props["onRemoveStatus"] = async (
frontmatterTagName,
status,
) => {
const removed =
await this.selectorService.noteStatusService.removeStatus(
frontmatterTagName,
status,
);
if (removed) {
new Notice(
`Status ${status.name} removed successfully from the note`,
);
} else {
new Notice(
`Something went wrong removing the status ${status.name} from the note`,
);
}
};
private onSelectStatus: Props["onSelectStatus"] = async (
frontmatterTagName,
status,
) => {
const added = await this.selectorService.noteStatusService.addStatus(
frontmatterTagName,
status.name,
);
if (added) {
new Notice(`Status ${status.name} added successfully to the note`);
} else {
new Notice(
`Something went wrong adding the status ${status.name} to the note`,
);
}
};
onOpen() {
this.render();
}
private render() {
const { contentEl, titleEl } = this;
titleEl.setText("Note Status");
// this.modalEl.addClass("note-status-modal");
if (!this.root) {
this.root = createRoot(contentEl);
}
let filesQuantity = 1;
if (
this.selectorService.noteStatusService instanceof
MultipleNoteStatusService
) {
filesQuantity =
this.selectorService.noteStatusService.selectedFilesQTY();
}
this.root.render(
<StatusModalComponent
availableStatuses={NoteStatusService.getAllAvailableStatuses()}
currentStatuses={
this.selectorService.noteStatusService.statuses ?? {}
}
filesQuantity={filesQuantity}
onRemoveStatus={this.onRemoveStatus}
onSelectStatus={this.onSelectStatus}
/>,
);
}
onClose() {
if (this.root) {
this.root.unmount();
this.root = null;
}
const { contentEl } = this;
contentEl.empty();
StatusModalIntegration.instance = null;
eventBus.unsubscribe(
"frontmatter-manually-changed",
"statusModalIntegrationSubscription1",
);
}
}

View file

@ -0,0 +1,47 @@
import { createRoot, Root } from "react-dom/client";
import { GroupedStatuses } from "@/types/noteStatus";
import { StatusFileInfoPopup } from "@/components/StatusFileInfoPopup/StatusFileInfoPopup";
export class StatusesInfoPopup {
private root: Root | null = null;
private static instance: StatusesInfoPopup | null = null;
private element: HTMLElement | null = null;
private statuses: GroupedStatuses;
private constructor() {}
static open(statuses: GroupedStatuses) {
StatusesInfoPopup.close();
StatusesInfoPopup.instance = new StatusesInfoPopup();
StatusesInfoPopup.instance.statuses = statuses;
StatusesInfoPopup.instance.show();
}
static close() {
if (StatusesInfoPopup.instance) {
StatusesInfoPopup.instance.destroy();
StatusesInfoPopup.instance = null;
}
}
private show() {
this.element = createDiv({ cls: "" });
document.body.appendChild(this.element);
this.root = createRoot(this.element);
this.root.render(<StatusFileInfoPopup statuses={this.statuses} />);
}
private destroy() {
if (this.root) {
this.root.unmount();
this.root = null;
}
if (this.element) {
this.element.remove();
this.element = null;
}
}
}

View file

@ -1,631 +0,0 @@
import React, { useCallback } from "react";
import { NoteStatusSettings, Status } from "../../models/types";
import { PREDEFINED_TEMPLATES } from "../../constants/status-templates";
import { SettingsUICallbacks } from "./types";
interface SettingsUIProps {
settings: NoteStatusSettings;
callbacks: SettingsUICallbacks;
}
interface TemplateSettingsProps {
settings: NoteStatusSettings;
callbacks: SettingsUICallbacks;
}
const TemplateSettings: React.FC<TemplateSettingsProps> = ({
settings,
callbacks,
}) => {
const handleTemplateToggle = useCallback(
(templateId: string, enabled: boolean) => {
callbacks.onTemplateToggle(templateId, enabled);
},
[callbacks],
);
return (
<div className="template-settings">
<h3>Status templates</h3>
<p className="setting-item-description">
Enable predefined templates to quickly add common status
workflows
</p>
<div className="templates-container">
{PREDEFINED_TEMPLATES.map((template) => {
const isEnabled = settings.enabledTemplates.includes(
template.id,
);
return (
<div key={template.id} className="template-item">
<div className="template-header">
<input
type="checkbox"
className="template-checkbox"
checked={isEnabled}
onChange={(e) =>
handleTemplateToggle(
template.id,
e.target.checked,
)
}
/>
<span className="template-name">
{template.name}
</span>
</div>
<div className="template-description">
{template.description}
</div>
<div className="template-statuses">
{template.statuses.map((status, index) => (
<div
key={index}
className="template-status-chip"
>
<span
className="status-color-dot"
style={
{
"--dot-color":
status.color ||
"#ffffff",
} as React.CSSProperties
}
/>
<span>
{status.icon} {status.name}
</span>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
);
};
interface UISettingsProps {
settings: NoteStatusSettings;
callbacks: SettingsUICallbacks;
}
const UISettings: React.FC<UISettingsProps> = ({ settings, callbacks }) => {
return (
<div className="ui-settings">
<h3>User interface</h3>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">Show status bar</div>
<div className="setting-item-description">
Display the status bar
</div>
</div>
<div className="setting-item-control">
<input
type="checkbox"
checked={settings.showStatusBar}
onChange={(e) =>
callbacks.onSettingChange(
"showStatusBar",
e.target.checked,
)
}
/>
</div>
</div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">
Auto-hide status bar
</div>
<div className="setting-item-description">
Hide the status bar when status is unknown
</div>
</div>
<div className="setting-item-control">
<input
type="checkbox"
checked={settings.autoHideStatusBar}
onChange={(e) =>
callbacks.onSettingChange(
"autoHideStatusBar",
e.target.checked,
)
}
/>
</div>
</div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">
Show status icons in file explorer
</div>
<div className="setting-item-description">
Display status icons in the file explorer
</div>
</div>
<div className="setting-item-control">
<input
type="checkbox"
checked={settings.showStatusIconsInExplorer}
onChange={(e) =>
callbacks.onSettingChange(
"showStatusIconsInExplorer",
e.target.checked,
)
}
/>
</div>
</div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">
Hide unknown status in file explorer
</div>
<div className="setting-item-description">
Hide status icons for files with unknown status in the
file explorer
</div>
</div>
<div className="setting-item-control">
<input
type="checkbox"
checked={settings.hideUnknownStatusInExplorer || false}
onChange={(e) =>
callbacks.onSettingChange(
"hideUnknownStatusInExplorer",
e.target.checked,
)
}
/>
</div>
</div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">
Default to compact view
</div>
<div className="setting-item-description">
Start the status pane in compact view by default
</div>
</div>
<div className="setting-item-control">
<input
type="checkbox"
checked={settings.compactView || false}
onChange={(e) =>
callbacks.onSettingChange(
"compactView",
e.target.checked,
)
}
/>
</div>
</div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">
Exclude unassigned notes from status pane
</div>
<div className="setting-item-description">
Improves performance by excluding notes with no assigned
status from the status pane. Recommended for large
vaults.
</div>
</div>
<div className="setting-item-control">
<input
type="checkbox"
checked={settings.excludeUnknownStatus || false}
onChange={(e) =>
callbacks.onSettingChange(
"excludeUnknownStatus",
e.target.checked,
)
}
/>
</div>
</div>
</div>
);
};
interface TagSettingsProps {
settings: NoteStatusSettings;
callbacks: SettingsUICallbacks;
}
const TagSettings: React.FC<TagSettingsProps> = ({ settings, callbacks }) => {
return (
<div className="tag-settings">
<h3>Status tag</h3>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">
Enable multiple statuses
</div>
<div className="setting-item-description">
Allow notes to have multiple statuses at the same time
</div>
</div>
<div className="setting-item-control">
<input
type="checkbox"
checked={settings.useMultipleStatuses}
onChange={(e) =>
callbacks.onSettingChange(
"useMultipleStatuses",
e.target.checked,
)
}
/>
</div>
</div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">Status tag prefix</div>
<div className="setting-item-description">
The YAML frontmatter tag name used for status (default:
obsidian-note-status)
</div>
</div>
<div className="setting-item-control">
<input
type="text"
value={settings.tagPrefix}
onChange={(e) => {
if (e.target.value.trim()) {
callbacks.onSettingChange(
"tagPrefix",
e.target.value.trim(),
);
}
}}
/>
</div>
</div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">
Strict status validation
</div>
<div className="setting-item-description">
Only show statuses that are defined in templates or
custom statuses. WARNING: When enabled, any unknown
statuses will be automatically removed when modifying
file statuses.
</div>
</div>
<div className="setting-item-control">
<input
type="checkbox"
checked={settings.strictStatuses || false}
onChange={(e) =>
callbacks.onSettingChange(
"strictStatuses",
e.target.checked,
)
}
/>
</div>
</div>
</div>
);
};
interface CustomStatusItemProps {
status: Status;
index: number;
settings: NoteStatusSettings;
callbacks: SettingsUICallbacks;
}
const CustomStatusItem: React.FC<CustomStatusItemProps> = ({
status,
index,
settings,
callbacks,
}) => {
return (
<div className="custom-status-item setting-item">
<div className="setting-item-info">
<div className="setting-item-name">{status.name}</div>
</div>
<div className="setting-item-controls">
<input
type="text"
placeholder="Name"
value={status.name}
onChange={(e) =>
callbacks.onCustomStatusChange(
index,
"name",
e.target.value || "unnamed",
)
}
/>
<input
type="text"
placeholder="Icon"
value={status.icon}
onChange={(e) =>
callbacks.onCustomStatusChange(
index,
"icon",
e.target.value || "❓",
)
}
/>
<input
type="color"
value={settings.statusColors[status.name] || "#ffffff"}
onChange={(e) =>
callbacks.onCustomStatusChange(
index,
"color",
e.target.value,
)
}
/>
<input
type="text"
placeholder="Description"
value={status.description || ""}
onChange={(e) =>
callbacks.onCustomStatusChange(
index,
"description",
e.target.value,
)
}
/>
<button
className="status-remove-button"
onClick={() => callbacks.onCustomStatusRemove(index)}
>
Remove
</button>
</div>
</div>
);
};
interface CustomStatusSettingsProps {
settings: NoteStatusSettings;
callbacks: SettingsUICallbacks;
}
const CustomStatusSettings: React.FC<CustomStatusSettingsProps> = ({
settings,
callbacks,
}) => {
return (
<div className="custom-status-settings">
<h3>Custom statuses</h3>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">
Use only custom statuses
</div>
<div className="setting-item-description">
Ignore template statuses and use only the custom
statuses defined below
</div>
</div>
<div className="setting-item-control">
<input
type="checkbox"
checked={settings.useCustomStatusesOnly || false}
onChange={(e) =>
callbacks.onSettingChange(
"useCustomStatusesOnly",
e.target.checked,
)
}
/>
</div>
</div>
<div className="custom-status-list">
{settings.customStatuses.map((status, index) => (
<CustomStatusItem
key={index}
status={status}
index={index}
settings={settings}
callbacks={callbacks}
/>
))}
</div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">Add new status</div>
<div className="setting-item-description">
Add a custom status with a name, icon, and color
</div>
</div>
<div className="setting-item-control">
<button
className="mod-cta"
onClick={() => callbacks.onCustomStatusAdd()}
>
Add Status
</button>
</div>
</div>
</div>
);
};
interface QuickCommandsSettingsProps {
settings: NoteStatusSettings;
callbacks: SettingsUICallbacks;
}
const QuickCommandsSettings: React.FC<QuickCommandsSettingsProps> = ({
settings,
callbacks,
}) => {
const getAllAvailableStatuses = useCallback((): Array<{
name: string;
icon: string;
description?: string;
}> => {
const statuses: Array<{
name: string;
icon: string;
description?: string;
}> = [];
statuses.push(...settings.customStatuses);
if (!settings.useCustomStatusesOnly) {
for (const templateId of settings.enabledTemplates) {
const template = PREDEFINED_TEMPLATES.find(
(t) => t.id === templateId,
);
if (template) {
for (const status of template.statuses) {
if (!statuses.find((s) => s.name === status.name)) {
statuses.push(status);
}
}
}
}
}
return statuses.filter((s) => s.name !== "unknown");
}, [settings]);
const allStatuses = getAllAvailableStatuses();
const currentQuickCommands = settings.quickStatusCommands || [];
const handleQuickCommandToggle = useCallback(
(statusName: string, enabled: boolean) => {
const updatedCommands = enabled
? [
...currentQuickCommands.filter(
(cmd) => cmd !== statusName,
),
statusName,
]
: currentQuickCommands.filter((cmd) => cmd !== statusName);
callbacks.onSettingChange("quickStatusCommands", updatedCommands);
},
[currentQuickCommands, callbacks],
);
return (
<div className="quick-commands-settings">
<h3>Quick status commands</h3>
<div className="setting-item-description">
Select which statuses should have dedicated commands in the
command palette. These can be assigned hotkeys for quick access.
</div>
<div className="quick-commands-container">
{allStatuses.length === 0 ? (
<div className="setting-item-description">
No statuses available. Enable templates or add custom
statuses first.
</div>
) : (
allStatuses.map((status) => (
<div key={status.name} className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">
{status.icon} {status.name}
</div>
{status.description && (
<div className="setting-item-description">
{status.description}
</div>
)}
</div>
<div className="setting-item-control">
<input
type="checkbox"
checked={currentQuickCommands.includes(
status.name,
)}
onChange={(e) =>
handleQuickCommandToggle(
status.name,
e.target.checked,
)
}
/>
</div>
</div>
))
)}
</div>
</div>
);
};
export const SettingsUI: React.FC<SettingsUIProps> = ({
settings,
callbacks,
}) => {
return (
<div className="note-status-settings">
<TemplateSettings settings={settings} callbacks={callbacks} />
<UISettings settings={settings} callbacks={callbacks} />
<TagSettings settings={settings} callbacks={callbacks} />
<CustomStatusSettings settings={settings} callbacks={callbacks} />
<QuickCommandsSettings settings={settings} callbacks={callbacks} />
</div>
);
};
export class NoteStatusSettingsUIManager {
private callbacks: SettingsUICallbacks;
private container: HTMLElement | null = null;
constructor(callbacks: SettingsUICallbacks) {
this.callbacks = callbacks;
}
render(containerEl: HTMLElement, settings: NoteStatusSettings): void {
this.container = containerEl;
containerEl.empty();
import("../../utils/react-utils").then(({ ReactUtils }) => {
ReactUtils.render(
React.createElement(SettingsUI, {
settings,
callbacks: this.callbacks,
}),
containerEl,
);
});
}
destroy(): void {
if (this.container) {
import("../../utils/react-utils").then(({ ReactUtils }) => {
ReactUtils.unmount(this.container!);
});
}
}
}
export default SettingsUI;

View file

@ -0,0 +1,52 @@
import SettingsUI from "@/components/SettingsUI.tsx";
import settingsService from "@/core/settingsService";
import { Plugin, PluginSettingTab } from "obsidian";
import { createRoot, Root } from "react-dom/client";
export class PluginSettingIntegration extends PluginSettingTab {
private static instance: PluginSettingIntegration | null = null;
private root: Root | null = null;
private plugin: Plugin;
constructor(plugin: Plugin) {
if (PluginSettingIntegration.instance) {
throw new Error("The status bar instance is already created");
}
super(plugin.app, plugin);
this.plugin = plugin;
PluginSettingIntegration.instance = this;
}
async integrate() {
// INFO: This will integrate the "display" function component
this.plugin.addSettingTab(this);
}
display(): void {
const { containerEl } = this;
if (this.root) {
this.root.unmount();
this.root = null;
}
containerEl.empty();
this.root = createRoot(containerEl);
this.root.render(
<SettingsUI
settings={settingsService.settings}
onChange={(key, value) => {
settingsService.setValue(key, value);
}}
/>,
);
}
destroy(): void {
if (this.root) {
this.root.unmount();
this.root = null;
}
PluginSettingIntegration.instance = null;
}
}

View file

@ -1,136 +0,0 @@
import { App } from "obsidian";
import { Status } from "../../models/types";
import NoteStatus from "main";
import { StatusService } from "services/status-service";
import { NoteStatusSettingsUIManager } from "./SettingsUI";
import { SettingsUICallbacks } from "./types";
/**
* Controller for settings UI, handles business logic and plugin state updates
*/
export class NoteStatusSettingsController implements SettingsUICallbacks {
private app: App;
private plugin: NoteStatus;
private statusService: StatusService;
private ui: NoteStatusSettingsUIManager;
constructor(app: App, plugin: NoteStatus, statusService: StatusService) {
this.app = app;
this.plugin = plugin;
this.statusService = statusService;
this.ui = new NoteStatusSettingsUIManager(this);
}
/**
* Renders the settings interface
*/
display(containerEl: HTMLElement): void {
this.ui.render(containerEl, this.plugin.settings);
}
/**
* Handles template enable/disable toggle
*/
onTemplateToggle: SettingsUICallbacks["onTemplateToggle"] = async (
templateId,
enabled,
) => {
if (enabled) {
if (!this.plugin.settings.enabledTemplates.includes(templateId)) {
this.plugin.settings.enabledTemplates.push(templateId);
}
} else {
this.plugin.settings.enabledTemplates =
this.plugin.settings.enabledTemplates.filter(
(id: string) => id !== templateId,
);
}
await this.plugin.saveSettings();
};
/**
* Handles general setting changes
*/
onSettingChange: SettingsUICallbacks["onSettingChange"] = async (
key,
value,
) => {
this.plugin.settings[key] = value;
await this.plugin.saveSettings();
};
/**
* Handles custom status field changes
*/
onCustomStatusChange: SettingsUICallbacks["onCustomStatusChange"] = async (
index,
field,
value,
) => {
const status = this.plugin.settings.customStatuses[index];
if (!status) return;
if (field === "name") {
const oldName = status.name;
status.name = value;
if (oldName !== status.name) {
this.plugin.settings.statusColors[status.name] =
this.plugin.settings.statusColors[oldName];
delete this.plugin.settings.statusColors[oldName];
}
} else if (field === "color") {
this.plugin.settings.statusColors[status.name] = value;
} else {
status[field] = value;
}
await this.plugin.saveSettings();
};
/**
* Handles custom status removal
*/
onCustomStatusRemove: SettingsUICallbacks["onCustomStatusRemove"] = async (
index,
) => {
const status = this.plugin.settings.customStatuses[index];
if (!status) return;
this.plugin.settings.customStatuses.splice(index, 1);
delete this.plugin.settings.statusColors[status.name];
await this.plugin.saveSettings();
// Re-render the entire settings interface
const container = document.querySelector(".note-status-settings")
?.parentElement as HTMLElement;
if (container) {
this.display(container);
}
};
/**
* Handles adding new custom status
*/
onCustomStatusAdd: SettingsUICallbacks["onCustomStatusAdd"] = async () => {
const newStatus: Status = {
name: `status${this.plugin.settings.customStatuses.length + 1}`,
icon: "⭐",
};
this.plugin.settings.customStatuses.push(newStatus);
this.plugin.settings.statusColors[newStatus.name] = "#ffffff";
await this.plugin.saveSettings();
// Re-render the entire settings interface
const container = document.querySelector(".note-status-settings")
?.parentElement as HTMLElement;
if (container) {
this.display(container);
}
};
}

View file

@ -1,27 +0,0 @@
import { App, PluginSettingTab } from "obsidian";
import NoteStatus from "main";
import { StatusService } from "services/status-service";
import { NoteStatusSettingsController } from "./settings-controller";
/**
* Settings tab for the Note Status plugin - delegates to controller
*/
export class NoteStatusSettingTab extends PluginSettingTab {
private controller: NoteStatusSettingsController;
constructor(app: App, plugin: NoteStatus, statusService: StatusService) {
super(app, plugin);
this.controller = new NoteStatusSettingsController(
app,
plugin,
statusService,
);
}
/**
* Displays the settings interface
*/
display(): void {
this.controller.display(this.containerEl);
}
}

View file

@ -1,24 +0,0 @@
import { NoteStatusSettings, Status } from "../../models/types";
/**
* Callbacks interface for settings UI interactions
*/
export interface SettingsUICallbacks {
/** Handle template enable/disable toggle */
onTemplateToggle: (templateId: string, enabled: boolean) => Promise<void>;
/** Handle general setting changes */
onSettingChange: (
key: keyof NoteStatusSettings,
value: boolean | string | string[],
) => Promise<void>;
/** Handle custom status field changes */
onCustomStatusChange: (
index: number,
field: keyof Status,
value: string,
) => Promise<void>;
/** Handle custom status removal */
onCustomStatusRemove: (index: number) => Promise<void>;
/** Handle adding new custom status */
onCustomStatusAdd: () => Promise<void>;
}

View file

@ -0,0 +1,161 @@
import { createRoot, Root } from "react-dom/client";
import eventBus from "core/eventBus";
import { MarkdownView, Plugin, WorkspaceLeaf } from "obsidian";
import settingsService from "@/core/settingsService";
import { StatusBarEnableButton } from "@/components/StatusBar/StatusBarEnableButton";
import { StatusBar } from "@/components/StatusBar/StatusBar";
import { NoteStatusService } from "@/core/noteStatusService";
export class StatusBarIntegration {
private static instance: StatusBarIntegration | null = null;
private root: Root | null = null;
private plugin: Plugin;
private statusBarContainer: HTMLElement;
private noteStatusService: NoteStatusService | null = null;
private currentLeaf: WorkspaceLeaf | null = null;
constructor(plugin: Plugin) {
if (StatusBarIntegration.instance) {
throw new Error("The status bar instance is already created");
}
this.plugin = plugin;
StatusBarIntegration.instance = this;
}
async integrate() {
this.statusBarContainer = this.plugin.addStatusBarItem();
this.statusBarContainer.classList.add("mod-clickable");
eventBus.subscribe(
"active-file-change",
({ leaf }) => {
if (this.isValidMarkdownLeaf(leaf)) {
this.currentLeaf = leaf;
this.handleActiveFileChange().catch(console.error);
}
},
"statusBarIntegrationSubscription1",
);
eventBus.subscribe(
"plugin-settings-changed",
({ value, key }) => {
if (key === "showStatusBar") {
this.render(); // INFO: Force a render to set disabled or enabled
}
if (key === "enabledTemplates") {
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
}
if (key === "autoHideStatusBar") {
this.render();
}
if (key === "useMultipleStatuses") {
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
}
if (key === "tagPrefix") {
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
}
if (key === "useCustomStatusesOnly") {
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
}
if (key === "customStatuses") {
this.handleActiveFileChange().catch(console.error); // INFO: Force a re-read of the statuses and render
}
},
"statusBarIntegrationSubscription2",
);
eventBus.subscribe(
"frontmatter-manually-changed",
() => {
this.handleActiveFileChange().catch(console.error);
},
"statusBarIntegrationSubscription3",
);
}
private async handleActiveFileChange() {
this.extractStatusesFromLeaf(this.currentLeaf);
this.render();
}
private extractStatusesFromLeaf(leaf: WorkspaceLeaf | null) {
if (!this.isValidMarkdownLeaf(leaf)) {
return {};
}
const markdownView = leaf!.view as MarkdownView;
if (!markdownView.file) {
return {};
}
this.noteStatusService = new NoteStatusService(markdownView.file);
this.noteStatusService.populateStatuses();
}
private isValidMarkdownLeaf(leaf: WorkspaceLeaf | null): boolean {
return leaf !== null && leaf.view.getViewType() === "markdown";
}
private openStatusModal() {
if (!this.noteStatusService) {
throw new Error(
"open status modal failed bcse there is no noteStatusService available",
);
}
eventBus.publish("triggered-open-modal", {
statusService: this.noteStatusService,
});
}
private render() {
if (!this.root) {
this.root = createRoot(this.statusBarContainer);
this.statusBarContainer.style.padding = "unset";
}
if (!settingsService.settings.showStatusBar) {
this.root.render(
<StatusBarEnableButton
onEnableClick={() =>
settingsService.setValue("showStatusBar", true)
}
/>,
);
} else {
this.root.render(
<StatusBar
statuses={this.noteStatusService?.statuses || {}}
hideIfNotStatuses={
settingsService.settings.autoHideStatusBar
}
onStatusClick={() => this.openStatusModal()}
/>,
);
}
}
destroy() {
eventBus.unsubscribe(
"active-file-change",
"statusBarIntegrationSubscription1",
);
eventBus.unsubscribe(
"plugin-settings-changed",
"statusBarIntegrationSubscription2",
);
eventBus.unsubscribe(
"plugin-settings-changed",
"statusBarIntegrationSubscription3",
);
if (this.root) {
this.root.unmount();
this.root = null;
}
this.currentLeaf = null;
this.noteStatusService = null;
StatusBarIntegration.instance = null;
}
}
export default StatusBarIntegration;

View file

@ -0,0 +1,170 @@
import { ItemView, WorkspaceLeaf, TFile } from "obsidian";
import { Root, createRoot } from "react-dom/client";
import { GrouppedStatusView as GrouppedStatusViewComponent } from "@/components/GrouppedStatusView/GrouppedStatusView";
import { BaseNoteStatusService } from "@/core/noteStatusService";
import eventBus from "@/core/eventBus";
import settingsService from "@/core/settingsService";
import { NoteStatus } from "@/types/noteStatus";
export const VIEW_TYPE_GROUPPED_DASHBOARD = "groupped-dashboard-view";
interface FileItem {
id: string;
name: string;
path: string;
}
interface StatusItem {
name: string;
color: string;
icon?: string;
}
interface FilesByStatus {
[statusName: string]: FileItem[];
}
interface GroupedByStatus {
[frontmatterTag: string]: FilesByStatus;
}
export class GrouppedDashboardView extends ItemView {
root: Root | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType() {
return VIEW_TYPE_GROUPPED_DASHBOARD;
}
getDisplayText() {
return "Groupped Status Dashboard";
}
getIcon() {
return "list-tree";
}
private convertTFileToFileItem = (file: TFile): FileItem => ({
id: file.path,
name: file.basename,
path: file.path,
});
private convertStatusToStatusItem = (status: NoteStatus): StatusItem => ({
name: status.name,
color: status.color || "",
icon: status.icon,
});
private getAllFiles = (): FileItem[] => {
const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles();
return tFiles.map(this.convertTFileToFileItem);
};
private processFiles = (files: FileItem[]): GroupedByStatus => {
const result: GroupedByStatus = {};
const statusMetadataKeys = [settingsService.settings.tagPrefix];
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
const statusMap = new Map(availableStatuses.map((s) => [s.name, s]));
statusMetadataKeys.forEach((key) => {
result[key] = {};
availableStatuses.forEach((status) => {
result[key][status.name] = [];
});
});
files.forEach((file) => {
// Find the TFile to get metadata
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
file.path,
) as TFile;
if (!tFile) return;
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(tFile);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return;
statusMetadataKeys.forEach((key) => {
const value = frontmatter[key];
if (value) {
const statusNames = Array.isArray(value) ? value : [value];
statusNames.forEach((statusName) => {
const statusStr = statusName.toString();
if (statusMap.has(statusStr)) {
if (!result[key][statusStr]) {
result[key][statusStr] = [];
}
result[key][statusStr].push(file);
}
});
}
});
});
return result;
};
private getAvailableStatuses = (): StatusItem[] => {
const statuses = BaseNoteStatusService.getAllAvailableStatuses();
return statuses.map(this.convertStatusToStatusItem);
};
private handleFileClick = (file: FileItem) => {
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
file.path,
) as TFile;
if (tFile) {
const leaf = BaseNoteStatusService.app.workspace.getLeaf();
leaf.openFile(tFile);
}
};
private subscribeToEvents = (onDataChange: () => void) => {
eventBus.subscribe(
"frontmatter-manually-changed",
onDataChange,
"groupped-dashboard-view-subscription",
);
const unsubscribeActiveFile = BaseNoteStatusService.app.workspace.on(
"active-leaf-change",
onDataChange,
);
return () => {
eventBus.unsubscribe(
"frontmatter-manually-changed",
"groupped-dashboard-view-subscription",
);
BaseNoteStatusService.app.workspace.offref(unsubscribeActiveFile);
};
};
async onOpen() {
const container = this.containerEl.children[1];
container.empty();
container.addClass("groupped-dashboard-view-container");
this.root = createRoot(container);
this.root.render(
<GrouppedStatusViewComponent
getAllFiles={this.getAllFiles}
processFiles={this.processFiles}
onFileClick={this.handleFileClick}
subscribeToEvents={this.subscribeToEvents}
getAvailableStatuses={this.getAvailableStatuses}
/>,
);
}
async onClose() {
this.root?.unmount();
this.root = null;
}
}

View file

@ -0,0 +1,155 @@
import { ItemView, WorkspaceLeaf, TFile } from "obsidian";
import { Root, createRoot } from "react-dom/client";
import {
FileItem,
GroupedByStatus,
GrouppedStatusView as GrouppedStatusViewComponent,
StatusItem,
} from "@/components/GrouppedStatusView/GrouppedStatusView";
import { BaseNoteStatusService } from "@/core/noteStatusService";
import eventBus from "@/core/eventBus";
import settingsService from "@/core/settingsService";
import { NoteStatus } from "@/types/noteStatus";
export const VIEW_TYPE_EXAMPLE = "groupped-status-view";
export class GrouppedStatusView extends ItemView {
root: Root | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType() {
return VIEW_TYPE_EXAMPLE;
}
getDisplayText() {
return "Groupped Status View";
}
getIcon() {
return "list-tree";
}
private convertTFileToFileItem = (file: TFile): FileItem => ({
id: file.path,
name: file.basename,
path: file.path,
});
private convertStatusToStatusItem = (status: NoteStatus): StatusItem => ({
name: status.name,
color: status.color || "white",
icon: status.icon,
});
private getAllFiles = (): FileItem[] => {
const tFiles = BaseNoteStatusService.app.vault.getMarkdownFiles();
return tFiles.map(this.convertTFileToFileItem);
};
private processFiles = (files: FileItem[]): GroupedByStatus => {
const result: GroupedByStatus = {};
const statusMetadataKeys = [settingsService.settings.tagPrefix];
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
const statusMap = new Map(availableStatuses.map((s) => [s.name, s]));
statusMetadataKeys.forEach((key) => {
result[key] = {};
availableStatuses.forEach((status) => {
result[key][status.name] = [];
});
});
files.forEach((file) => {
// Find the TFile to get metadata
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
file.path,
) as TFile;
if (!tFile) return;
const cachedMetadata =
BaseNoteStatusService.app.metadataCache.getFileCache(tFile);
const frontmatter = cachedMetadata?.frontmatter;
if (!frontmatter) return;
statusMetadataKeys.forEach((key) => {
const value = frontmatter[key];
if (value) {
const statusNames = Array.isArray(value) ? value : [value];
statusNames.forEach((statusName) => {
const statusStr = statusName.toString();
if (statusMap.has(statusStr)) {
if (!result[key][statusStr]) {
result[key][statusStr] = [];
}
result[key][statusStr].push(file);
}
});
}
});
});
return result;
};
private getAvailableStatuses = (): StatusItem[] => {
const statuses = BaseNoteStatusService.getAllAvailableStatuses();
return statuses.map(this.convertStatusToStatusItem);
};
private handleFileClick = (file: FileItem) => {
const tFile = BaseNoteStatusService.app.vault.getAbstractFileByPath(
file.path,
) as TFile;
if (tFile) {
const leaf = BaseNoteStatusService.app.workspace.getLeaf();
leaf.openFile(tFile);
}
};
private subscribeToEvents = (onDataChange: () => void) => {
eventBus.subscribe(
"frontmatter-manually-changed",
onDataChange,
"groupped-status-view-subscription",
);
const unsubscribeActiveFile = BaseNoteStatusService.app.workspace.on(
"active-leaf-change",
onDataChange,
);
return () => {
eventBus.unsubscribe(
"frontmatter-manually-changed",
"groupped-status-view-subscription",
);
BaseNoteStatusService.app.workspace.offref(unsubscribeActiveFile);
};
};
async onOpen() {
const container = this.containerEl.children[1];
container.empty();
container.addClass("groupped-status-view-container");
this.root = createRoot(container);
this.root.render(
<GrouppedStatusViewComponent
getAllFiles={this.getAllFiles}
processFiles={this.processFiles}
onFileClick={this.handleFileClick}
subscribeToEvents={this.subscribeToEvents}
getAvailableStatuses={this.getAvailableStatuses}
/>,
);
}
async onClose() {
this.root?.unmount();
this.root = null;
}
}

View file

@ -0,0 +1,39 @@
import { ItemView, WorkspaceLeaf } from "obsidian";
import { Root, createRoot } from "react-dom/client";
import { StatusDashboard } from "@/components/StatusDashboard/StatusDashboard";
export const VIEW_TYPE_STATUS_DASHBOARD = "status-dashboard-view";
export class StatusDashboardView extends ItemView {
root: Root | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType() {
return VIEW_TYPE_STATUS_DASHBOARD;
}
getDisplayText() {
return "Status Dashboard";
}
getIcon() {
return "bar-chart-2";
}
async onOpen() {
const container = this.containerEl.children[1];
container.empty();
container.addClass("status-dashboard-view-container");
this.root = createRoot(container);
this.root.render(<StatusDashboard />);
}
async onClose() {
this.root?.unmount();
this.root = null;
}
}

View file

@ -1 +0,0 @@
export { WorkspaceIntegration } from "./workspace-integration";

View file

@ -1,131 +0,0 @@
import { App, TFile, WorkspaceLeaf } from "obsidian";
import { NoteStatusSettings } from "../../models/types";
import { ToolbarIntegration } from "../editor/toolbar-integration";
import { StatusService } from "services/status-service";
/**
* Gestiona la integración con el workspace de Obsidian
*/
export class WorkspaceIntegration {
private app: App;
private settings: NoteStatusSettings;
private statusService: StatusService;
private toolbarIntegration: ToolbarIntegration;
private lastActiveFile: TFile | null = null;
private fileOpenEventRef: (file: TFile) => void;
private activeLeafChangeEventRef: (file: WorkspaceLeaf) => void;
private layoutChangeEventRef: () => void;
constructor(
app: App,
settings: NoteStatusSettings,
statusService: StatusService,
toolbarIntegration: ToolbarIntegration,
) {
this.app = app;
this.settings = settings;
this.statusService = statusService;
this.toolbarIntegration = toolbarIntegration;
}
/**
* Actualiza la configuración
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
/**
* Registra eventos del workspace
*/
public registerWorkspaceEvents(): void {
this.fileOpenEventRef = (file: TFile) => {
if (file instanceof TFile) {
this.handleFileOpen(file);
}
};
this.activeLeafChangeEventRef = (leaf: WorkspaceLeaf) => {
this.handleActiveLeafChange(leaf);
};
this.layoutChangeEventRef = () => {
this.handleLayoutChange();
};
this.app.workspace.on("file-open", this.fileOpenEventRef);
this.app.workspace.on(
"active-leaf-change",
this.activeLeafChangeEventRef,
);
this.app.workspace.on("layout-change", this.layoutChangeEventRef);
}
/**
* Maneja apertura de archivo
*/
private handleFileOpen(file: TFile): void {
// Añade el botón de la barra de herramientas
this.toolbarIntegration.addToolbarButtonToActiveLeaf();
// Actualiza estado
this.propagateNoteStatusChange(file);
}
/**
* Maneja cambio de hoja activa
*/
private handleActiveLeafChange(leaf: WorkspaceLeaf): void {
// Añade el botón de la barra de herramientas
this.toolbarIntegration.addToolbarButtonToActiveLeaf();
//const activeFile = this.app.workspace.getActiveFile();
// Solo actualiza si el archivo realmente cambió
// if (this.lastActiveFile?.path !== activeFile?.path) {
// this.lastActiveFile = activeFile;
// this.propagateNoteStatusChange();
// }
}
/**
* Maneja y propaga el cambio de layout
*/
private handleLayoutChange(): void {
window.dispatchEvent(new CustomEvent("note-status:update-pane"));
}
/**
* Verifica y propaga el estado de la nota activa
*/
private propagateNoteStatusChange(file: TFile): void {
try {
const activeFile = this.app.workspace.getActiveFile();
let fileStatuses: string[] = [];
if (activeFile && activeFile.extension === "md") {
fileStatuses = this.statusService.getFileStatuses(activeFile);
}
// Dispara evento para que otros componentes se actualicen
window.dispatchEvent(
new CustomEvent("note-status:status-changed", {
detail: { statuses: fileStatuses, file: file },
}),
);
} catch (error) {
console.error("Error checking note status:", error);
}
}
public unload(): void {
if (this.fileOpenEventRef) {
this.app.workspace.off("file-open", this.fileOpenEventRef);
}
if (this.activeLeafChangeEventRef) {
this.app.workspace.off(
"active-leaf-change",
this.activeLeafChangeEventRef,
);
}
if (this.layoutChangeEventRef) {
this.app.workspace.off("layout-change", this.layoutChangeEventRef);
}
}
}

407
main.tsx
View file

@ -1,274 +1,191 @@
// React import needed for JSX compilation
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import React from "react";
import { Plugin, Notice } from "obsidian";
import { DEFAULT_SETTINGS } from "./constants/defaults";
import { NoteStatusSettings } from "./models/types";
import { StatusService } from "services/status-service";
import { StyleService } from "services/style-service";
import { ReactUtils } from "./utils/react-utils";
import { Plugin, WorkspaceLeaf } from "obsidian";
import StatusBarIntegration from "integrations/status-bar/status-bar";
import eventBus from "core/eventBus";
import { PluginSettingIntegration } from "./integrations/settings/pluginSettings";
import settingsService from "./core/settingsService";
import { BaseNoteStatusService } from "./core/noteStatusService";
import { StatusModalIntegration } from "./integrations/modals/statusModalIntegration";
import ContextMenuIntegration from "./integrations/context-menu/contextMenuIntegration";
import { FileExplorerIntegration } from "./integrations/file-explorer/file-explorer-integration";
import { CommandsIntegration } from "./integrations/commands/commandsIntegration";
import {
GrouppedStatusView,
VIEW_TYPE_EXAMPLE,
} from "./integrations/views/groupped-status-view";
import {
StatusDashboardView,
VIEW_TYPE_STATUS_DASHBOARD,
} from "./integrations/views/status-dashboard-view";
// Importar integraciones
import { ExplorerIntegration } from "./integrations/explorer";
import { EditorIntegration, ToolbarIntegration } from "./integrations/editor";
import { MetadataIntegration } from "./integrations/metadata-cache";
import { WorkspaceIntegration } from "./integrations/workspace";
import { FileContextMenuIntegration } from "integrations/context-menu/file-context-menu-integration";
import { NoteStatusSettingTab } from "integrations/settings/settings-tab";
import { CommandIntegration } from "integrations/commands/command-integration";
// Importar vistas
import { StatusPaneViewController } from "./views/status-pane-view/StatusPaneViewController";
// Importar componentes UI
import { StatusBar } from "components/status-bar";
import { StatusDropdownManager } from "components/status-dropdown/StatusDropdownManager";
import { StatusContextMenu } from "integrations/context-menu/status-context-menu";
export default class NoteStatus extends Plugin {
settings: NoteStatusSettings;
// Servicios
statusService: StatusService;
styleService: StyleService;
// Componentes UI
statusBar: StatusBar;
statusDropdown: StatusDropdownManager;
// Integraciones
explorerIntegration: ExplorerIntegration;
fileContextMenuIntegration: FileContextMenuIntegration;
editorIntegration: EditorIntegration;
toolbarIntegration: ToolbarIntegration;
metadataIntegration: MetadataIntegration;
workspaceIntegration: WorkspaceIntegration;
commandIntegration: CommandIntegration;
statusPane: StatusPaneViewController;
private boundHandleStatusChanged: (event: CustomEvent) => void;
export default class NoteStatusPlugin extends Plugin {
private statusBarIntegration: StatusBarIntegration;
private pluginSettingsIntegration: PluginSettingIntegration;
private contextMenuIntegration: ContextMenuIntegration;
private fileExplorerIntegration: FileExplorerIntegration;
private commandsIntegration: CommandsIntegration;
async onload() {
try {
await this.loadSettings();
this.initializeServices();
this.registerViews();
this.initializeUI();
this.initializeIntegrations();
this.setupCustomEvents();
} catch (error) {
console.error("Error loading Note Status plugin:", error);
new Notice(
"Error loading Note Status plugin. Check console for details.",
);
}
}
BaseNoteStatusService.initialize(this.app);
await this.loadPluginSettings();
private async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData(),
// INFO: initialize all integrations
Promise.all([
this.loadContextMenu(),
this.loadStatusBar(),
this.loadFileExplorer(),
this.loadCommands(),
this.loadEventBus(),
]);
this.registerView(
VIEW_TYPE_EXAMPLE,
(leaf) => new GrouppedStatusView(leaf),
);
}
private initializeServices() {
this.statusService = new StatusService(this.app, this.settings);
this.styleService = new StyleService(this.settings);
}
this.registerView(
VIEW_TYPE_STATUS_DASHBOARD,
(leaf) => new StatusDashboardView(leaf),
);
private registerViews() {
// Register status pane view
this.registerView("status-pane", (leaf) => {
this.statusPane = new StatusPaneViewController(leaf, this);
return this.statusPane;
this.addRibbonIcon("dice", "Activate grouped view", () => {
this.activateView();
});
// Add ribbon icon
this.addRibbonIcon("tag", "Open status pane", () => {
this.openStatusPane();
this.addRibbonIcon("bar-chart-2", "Status Dashboard", () => {
this.activateDashboard();
});
}
// Añadir pestaña de configuración
this.addSettingTab(
new NoteStatusSettingTab(this.app, this, this.statusService),
async onunload() {
// Clean up all integrations
this.statusBarIntegration?.destroy();
this.contextMenuIntegration?.destroy();
this.fileExplorerIntegration?.destroy();
this.commandsIntegration?.destroy();
this.pluginSettingsIntegration?.destroy();
// Clean up event subscriptions
eventBus.unsubscribe(
"triggered-open-modal",
"main-triggered-open-modal-subscriptor",
);
}
private initializeIntegrations() {
this.explorerIntegration = new ExplorerIntegration(
this.app,
this.settings,
this.statusService,
);
this.toolbarIntegration = new ToolbarIntegration(
this.app,
this.settings,
this.statusService,
this.statusDropdown,
);
const statusContextMenu = new StatusContextMenu(
this.app,
this.settings,
this.statusService,
this.statusDropdown,
this.explorerIntegration,
);
this.fileContextMenuIntegration = new FileContextMenuIntegration(
this.app,
this.settings,
this.statusService,
this.explorerIntegration,
statusContextMenu,
);
async activateView() {
const { workspace } = this.app;
this.editorIntegration = new EditorIntegration(
this.app,
this.settings,
this.statusService,
this.statusDropdown,
);
this.commandIntegration = new CommandIntegration(
this.app,
this,
this.settings,
this.statusService,
this.statusDropdown,
);
this.metadataIntegration = new MetadataIntegration(
this.app,
this.settings,
this.statusService,
this.explorerIntegration,
);
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(VIEW_TYPE_EXAMPLE);
this.workspaceIntegration = new WorkspaceIntegration(
this.app,
this.settings,
this.statusService,
this.toolbarIntegration,
);
// Registrar eventos en cada integración
this.fileContextMenuIntegration.registerFileContextMenuEvents();
this.editorIntegration.registerEditorMenus();
this.commandIntegration.registerCommands();
this.metadataIntegration.registerMetadataEvents();
this.workspaceIntegration.registerWorkspaceEvents();
}
private setupCustomEvents() {
this.boundHandleStatusChanged = this.handleStatusChanged.bind(this);
window.addEventListener(
"note-status:status-changed",
this.boundHandleStatusChanged,
);
}
private initializeUI() {
this.statusDropdown = new StatusDropdownManager(
this.app,
this.settings,
this.statusService,
);
// Inicializar barra de estado
this.statusBar = new StatusBar(
this.addStatusBarItem(),
this.settings,
this.statusService,
);
// Inicializar iconos del explorador (con retraso para evitar ralentizar el inicio)
if (this.settings.showStatusIconsInExplorer) {
setTimeout(() => {
this.explorerIntegration.updateAllFileExplorerIcons();
}, 2000);
}
}
private handleStatusChanged(event: CustomEvent) {
const { statuses, file } = event.detail;
// Actualizar barra de estado
this.statusBar.update(statuses);
// Actualizar toolbar
const activeFile = this.app.workspace.getActiveFile();
if (activeFile?.path === file) {
this.toolbarIntegration.updateStatusDisplay(statuses);
}
// Actualizar dropdown
this.statusDropdown.update(statuses);
// Actualizar status pane si está abierto
const statusPaneLeaf =
this.app.workspace.getLeavesOfType("status-pane")[0];
if (
statusPaneLeaf?.view &&
statusPaneLeaf.view instanceof StatusPaneViewController
) {
(statusPaneLeaf.view as StatusPaneViewController).update();
}
// Actualizar explorador si es necesario
if (this.settings.showStatusIconsInExplorer && file) {
const fileObj = this.app.vault.getFileByPath(file);
if (fileObj) {
this.explorerIntegration.updateFileExplorerIcons(fileObj);
if (leaves.length > 0) {
// A leaf with our view already exists, use that
leaf = leaves[0];
} else {
// Our view could not be found in the workspace, create a new leaf
// in the right sidebar for it
leaf = workspace.getRightLeaf(false);
if (!leaf) {
console.error(
"getRightLeaf return null, unable to setup the view",
);
} else {
await leaf.setViewState({
type: VIEW_TYPE_EXAMPLE,
active: true,
});
}
}
// "Reveal" the leaf in case it is in a collapsed sidebar
if (!leaf) {
console.error("leaf not found, unable to activate the view");
} else {
workspace.revealLeaf(leaf);
}
}
private async openStatusPane() {
await StatusPaneViewController.open(this.app);
}
async activateDashboard() {
const { workspace } = this.app;
async saveSettings() {
await this.saveData(this.settings);
let leaf: WorkspaceLeaf | null = null;
const leaves = workspace.getLeavesOfType(VIEW_TYPE_STATUS_DASHBOARD);
// Actualizar servicios
this.statusService.updateSettings(this.settings);
this.styleService.updateSettings(this.settings);
// Actualizar integraciones
this.explorerIntegration.updateSettings(this.settings);
this.fileContextMenuIntegration.updateSettings(this.settings);
this.editorIntegration.updateSettings(this.settings);
this.metadataIntegration?.updateSettings(this.settings);
this.commandIntegration?.updateSettings(this.settings);
this.toolbarIntegration.updateSettings(this.settings);
this.workspaceIntegration.updateSettings(this.settings);
this.statusPane?.updateSettings(this.settings);
// Actualizar componentes UI
this.statusBar.updateSettings(this.settings);
this.statusDropdown.updateSettings(this.settings);
}
onunload() {
// Clean up event listeners
if (this.boundHandleStatusChanged) {
window.removeEventListener(
"note-status:status-changed",
this.boundHandleStatusChanged,
);
if (leaves.length > 0) {
// A leaf with our view already exists, use that
leaf = leaves[0];
} else {
// Our view could not be found in the workspace, create a new leaf
// in the right sidebar for it
leaf = workspace.getRightLeaf(false);
if (!leaf) {
console.error(
"getRightLeaf return null, unable to setup the dashboard",
);
} else {
await leaf.setViewState({
type: VIEW_TYPE_STATUS_DASHBOARD,
active: true,
});
}
}
// Clean up integrations
this.explorerIntegration?.unload();
this.toolbarIntegration?.unload();
this.fileContextMenuIntegration?.unload();
this.workspaceIntegration?.unload();
this.metadataIntegration?.unload();
this.editorIntegration?.unload();
this.commandIntegration?.unload();
// "Reveal" the leaf in case it is in a collapsed sidebar
if (!leaf) {
console.error("leaf not found, unable to activate the dashboard");
} else {
workspace.revealLeaf(leaf);
}
}
// Clean up services
this.styleService?.unload();
private async loadEventBus() {
// Propagate to custom event bus the new active file
this.app.workspace.on(
"active-leaf-change",
(leaf: WorkspaceLeaf | null) => {
eventBus.publish("active-file-change", { leaf });
},
);
// Clean up UI components
this.statusBar?.unload();
this.statusDropdown?.unload();
// Propagate to custom event bus the manually frontmatter data
this.registerEvent(
this.app.metadataCache.on("changed", (file) => {
eventBus.publish("frontmatter-manually-changed", { file });
}),
);
// Clean up all React roots
ReactUtils.cleanup();
// Register listeners
eventBus.subscribe(
"triggered-open-modal",
({ statusService }) => {
StatusModalIntegration.open(this.app, statusService);
},
"main-triggered-open-modal-subscriptor",
);
}
async loadPluginSettings() {
// INFO: Loads the settings data
await settingsService.initialize(this);
// INFO: Integrates the plugin settings section
this.pluginSettingsIntegration = new PluginSettingIntegration(this);
await this.pluginSettingsIntegration.integrate();
}
private async loadContextMenu() {
this.contextMenuIntegration = new ContextMenuIntegration(this);
await this.contextMenuIntegration.integrate();
}
private async loadStatusBar() {
this.statusBarIntegration = new StatusBarIntegration(this);
await this.statusBarIntegration.integrate();
}
private async loadFileExplorer() {
this.fileExplorerIntegration = new FileExplorerIntegration(this);
await this.fileExplorerIntegration.integrate();
}
private async loadCommands() {
this.commandsIntegration = new CommandsIntegration(this);
await this.commandsIntegration.integrate();
}
}

View file

@ -1,76 +0,0 @@
import { TFile, View } from "obsidian";
declare module "obsidian" {
interface WorkspaceLeaf {
// TODO: Is deprecated, fix me
id: string;
}
interface Commands {
removeCommand(id: string): void;
}
interface App {
clipboard?: unknown;
internalPlugins: InternalPlugins;
commands: Commands;
}
interface GlobalSearchPlugin {
openGlobalSearch(query: string): void;
}
interface InternalPlugins {
getPluginById(
id: "global-search",
): { instance: GlobalSearchPlugin } | undefined;
}
}
/**
* Defines a status with name, icon and color
*/
export interface Status {
name: string;
icon: string;
color?: string; // Optional color property
description?: string; // Optional description property
[key: string]: unknown;
}
/**
* Plugin settings interface
*/
export interface NoteStatusSettings {
statusColors: Record<string, string>;
showStatusBar: boolean;
autoHideStatusBar: boolean;
customStatuses: Status[];
showStatusIconsInExplorer: boolean;
hideUnknownStatusInExplorer: boolean;
collapsedStatuses: Record<string, boolean>;
compactView: boolean;
enabledTemplates: string[]; // IDs of enabled templates
useCustomStatusesOnly: boolean; // Whether to use only custom statuses or include templates
useMultipleStatuses: boolean; // Whether to allow multiple statuses per note
tagPrefix: string; // Prefix for the status tag (default: 'status')
strictStatuses: boolean; // Whether to only show known statuses
excludeUnknownStatus: boolean; // Whether to exclude files with unknown status from the status pane
quickStatusCommands: string[];
[key: string]: unknown;
}
/**
* Extended interface for file explorer view
*/
export interface FileExplorerView extends View {
fileItems: Record<
string,
{
el?: HTMLElement;
file?: TFile;
titleEl?: HTMLElement;
selfEl?: HTMLElement;
}
>;
}

481
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,434 +0,0 @@
import { App, TFile, Editor, Notice } from "obsidian";
import { NoteStatusSettings, Status } from "../models/types";
import { PREDEFINED_TEMPLATES } from "../constants/status-templates";
/**
* Service for handling note status operations
*/
export class StatusService {
private app: App;
private settings: NoteStatusSettings;
private allStatuses: Status[] = [];
constructor(app: App, settings: NoteStatusSettings) {
this.app = app;
this.settings = settings;
this.updateAllStatuses();
}
/**
* Updates the settings reference and recalculates all statuses
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.updateAllStatuses();
}
/**
* Updates the combined list of all statuses (from templates and custom)
*/
private updateAllStatuses(): void {
// Start with custom statuses
this.allStatuses = [...this.settings.customStatuses];
// Add statuses from enabled templates if not using custom only
if (!this.settings.useCustomStatusesOnly) {
const templateStatuses = this.getTemplateStatuses();
for (const status of templateStatuses) {
const existingIndex = this.allStatuses.findIndex(
(s) => s.name.toLowerCase() === status.name.toLowerCase(),
);
if (existingIndex === -1) {
// Add new status
this.allStatuses.push(status);
} else if (status.color) {
// Update color in settings if it doesn't exist
if (!this.settings.statusColors[status.name]) {
this.settings.statusColors[status.name] = status.color;
}
}
}
}
}
/**
* Gets all statuses from enabled templates
*/
public getTemplateStatuses(): Status[] {
return this.settings.enabledTemplates
.map((id) => PREDEFINED_TEMPLATES.find((t) => t.id === id))
.filter(Boolean)
.flatMap((template) => (template ? template.statuses : []));
}
/**
* Get all available statuses (combined from templates and custom)
*/
public getAllStatuses(): Status[] {
return this.allStatuses;
}
/**
* Get the statuses of a file from its metadata
*/
public getFileStatuses(file: TFile): string[] {
const cachedMetadata = this.app.metadataCache.getFileCache(file);
if (!cachedMetadata?.frontmatter) return ["unknown"];
const frontmatterStatus =
cachedMetadata.frontmatter[this.settings.tagPrefix];
if (!frontmatterStatus) return ["unknown"];
const statuses: string[] = [];
if (Array.isArray(frontmatterStatus)) {
for (const statusName of frontmatterStatus) {
if (this.settings.strictStatuses) {
this.addValidStatus(statusName.toString(), statuses);
} else {
const cleanStatus = statusName.toString().trim();
if (cleanStatus) statuses.push(cleanStatus);
}
}
} else {
if (this.settings.strictStatuses) {
this.addValidStatus(frontmatterStatus.toString(), statuses);
} else {
const cleanStatus = frontmatterStatus.toString().trim();
if (cleanStatus) statuses.push(cleanStatus);
}
}
return statuses.length > 0 ? statuses : ["unknown"];
}
/**
* Add a status to the list if it's valid
*/
private addValidStatus(statusName: string, targetStatuses: string[]): void {
const normalizedStatus = statusName.toLowerCase();
const matchingStatus = this.allStatuses.find(
(s) => s.name.toLowerCase() === normalizedStatus,
);
if (matchingStatus) {
targetStatuses.push(matchingStatus.name);
}
}
/**
* Get the icon for a given status
*/
public getStatusIcon(status: string): string {
const customStatus = this.allStatuses.find(
(s) => s.name.toLowerCase() === status.toLowerCase(),
);
return customStatus ? customStatus.icon : "❓";
}
/**
* Insert status metadata in the editor
*/
public insertStatusMetadataInEditor(editor: Editor): void {
const content = editor.getValue();
const frontMatterMatch = content.match(/^---\n([\s\S]+?)\n---/);
// Check if status metadata already exists
const statusTagRegex = new RegExp(
`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`,
"m",
);
if (frontMatterMatch) {
const frontMatter = frontMatterMatch[1];
if (frontMatter.match(statusTagRegex)) {
// Status already exists, do nothing
return;
}
// Add to existing frontmatter
const defaultStatuses = ["unknown"];
const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
this.insertIntoExistingFrontmatter(
editor,
content,
frontMatterMatch,
statusMetadata,
);
} else {
// No frontmatter, check if status exists in content somehow
if (content.match(statusTagRegex)) {
return;
}
// Create new frontmatter
const defaultStatuses = ["unknown"];
const statusMetadata = `${this.settings.tagPrefix}: ${JSON.stringify(defaultStatuses)}`;
this.createFrontmatterWithStatus(editor, content, statusMetadata);
}
}
/**
* Insert status metadata into existing frontmatter
*/
private insertIntoExistingFrontmatter(
editor: Editor,
content: string,
frontMatterMatch: RegExpMatchArray,
statusMetadata: string,
): void {
const frontMatter = frontMatterMatch[1];
const statusTagRegex = new RegExp(
`${this.settings.tagPrefix}:\\s*\\[?[^\\]]*\\]?`,
"m",
);
const updatedFrontMatter = frontMatter.match(statusTagRegex)
? frontMatter.replace(statusTagRegex, statusMetadata)
: `${frontMatter}\n${statusMetadata}`;
const updatedContent = content.replace(
/^---\n([\s\S]+?)\n---/,
`---\n${updatedFrontMatter}\n---`,
);
editor.setValue(updatedContent);
}
/**
* Create new frontmatter with status metadata
*/
private createFrontmatterWithStatus(
editor: Editor,
content: string,
statusMetadata: string,
): void {
const newFrontMatter = `---\n${statusMetadata}\n---\n\n${content.trim()}`;
editor.setValue(newFrontMatter);
}
/**
* Get all markdown files with optional filtering
*/
public getMarkdownFiles(searchQuery = ""): TFile[] {
const files = this.app.vault.getMarkdownFiles();
if (!searchQuery) return files;
const lowerQuery = searchQuery.toLowerCase();
return files.filter((file) =>
file.basename.toLowerCase().includes(lowerQuery),
);
}
/**
* Group files by their status
*/
public groupFilesByStatus(searchQuery = ""): Record<string, TFile[]> {
const statusGroups: Record<string, TFile[]> = {};
// Initialize groups for all statuses
for (const status of this.allStatuses) {
statusGroups[status.name] = [];
}
// Ensure 'unknown' status exists
statusGroups["unknown"] = statusGroups["unknown"] || [];
// Get and process all files matching the search query
const files = this.getMarkdownFiles(searchQuery);
for (const file of files) {
const statuses = this.getFileStatuses(file);
for (const status of statuses) {
if (statusGroups[status]) {
statusGroups[status].push(file);
}
}
}
return statusGroups;
}
/**
* Centralizes all status modification operations
*/
private async modifyNoteStatus(options: {
files: TFile | TFile[];
statuses: string | string[];
operation: "set" | "add" | "remove" | "toggle";
showNotice?: boolean;
}): Promise<void> {
const { operation, showNotice = true } = options;
const targetFiles = Array.isArray(options.files)
? options.files
: [options.files];
const targetStatuses = Array.isArray(options.statuses)
? options.statuses
: [options.statuses];
if (targetFiles.length === 0) {
if (showNotice) new Notice("No files selected");
return;
}
// Process each file
const updatePromises = targetFiles.map(async (file) => {
if (!file || file.extension !== "md") return;
// Get current statuses for the file
const currentStatuses = this.getFileStatuses(file);
let newStatuses: string[] = [];
switch (operation) {
case "set":
// Replace all statuses with the new ones
newStatuses = [...targetStatuses];
break;
case "add":
// Add new statuses without duplicates
newStatuses = [
...new Set([
...currentStatuses.filter((s) => s !== "unknown"),
...targetStatuses,
]),
];
break;
case "remove":
// Remove specified statuses
newStatuses = currentStatuses.filter(
(status) => !targetStatuses.includes(status),
);
break;
case "toggle":
// Toggle each status (add if not present, remove if present)
newStatuses = [...currentStatuses];
for (const status of targetStatuses) {
if (currentStatuses.includes(status)) {
newStatuses = newStatuses.filter(
(s) => s !== status,
);
} else {
newStatuses = [
...newStatuses.filter((s) => s !== "unknown"),
status,
];
}
}
break;
}
// Handle empty result (should revert to unknown)
if (newStatuses.length === 0) {
newStatuses = ["unknown"];
}
// Apply updates to frontmatter
await this.app.fileManager.processFrontMatter(
file,
(frontmatter) => {
frontmatter[this.settings.tagPrefix] = newStatuses;
},
);
this.notifyStatusChanged(newStatuses, file);
});
await Promise.all(updatePromises);
// Show notification for batch operations
if (showNotice && targetFiles.length > 1) {
const statusText =
targetStatuses.length === 1
? targetStatuses[0]
: `${targetStatuses.length} statuses`;
const operationText =
operation === "set"
? "updated"
: operation === "add"
? "added to"
: operation === "remove"
? "removed from"
: "toggled on";
new Notice(
`${statusText} ${operationText} ${targetFiles.length} files`,
);
}
}
/**
* Handles UI-triggered status changes with appropriate logic based on context
* This is the central function that all UI components should call for status changes
*/
public async handleStatusChange(options: {
files: TFile | TFile[];
statuses: string | string[];
isMultipleSelection?: boolean;
allowMultipleStatuses?: boolean;
operation?: "set" | "add" | "remove" | "toggle";
showNotice?: boolean;
afterChange?: (updatedStatuses: string[]) => void;
}): Promise<void> {
const {
files,
statuses,
isMultipleSelection = false,
allowMultipleStatuses = this.settings.useMultipleStatuses,
operation: explicitOperation,
showNotice = isMultipleSelection,
} = options;
const targetFiles = Array.isArray(files) ? files : [files];
const targetStatuses = Array.isArray(statuses) ? statuses : [statuses];
// Determine operation based on context if not explicitly specified
let operation: "set" | "add" | "remove" | "toggle";
if (explicitOperation) {
operation = explicitOperation;
} else if (isMultipleSelection) {
// For multiple files, we need to check if we should add or remove
const firstStatus = targetStatuses[0]; // Use first status for multi-file operations
const filesWithStatus = targetFiles.filter((file) =>
this.getFileStatuses(file).includes(firstStatus),
);
operation =
filesWithStatus.length > targetFiles.length / 2
? "remove"
: "add";
} else {
// For single file operations
if (allowMultipleStatuses) {
operation = "toggle";
} else {
operation = "set";
}
}
// Apply the changes
await this.modifyNoteStatus({
files: targetFiles,
statuses: targetStatuses,
operation,
showNotice,
});
}
/**
* Dispatch status changed event
*/
private notifyStatusChanged(statuses: string[], file?: TFile): void {
// Dispatch the specific status change event
window.dispatchEvent(
new CustomEvent("note-status:status-changed", {
detail: {
statuses,
file: file?.path,
},
}),
);
}
}

View file

@ -1,104 +0,0 @@
import { NoteStatusSettings } from "../models/types";
import { PREDEFINED_TEMPLATES } from "../constants/status-templates";
/**
* Handles dynamic styling for the plugin
*/
export class StyleService {
private dynamicStyleEl?: HTMLStyleElement;
private settings: NoteStatusSettings;
constructor(settings: NoteStatusSettings) {
this.settings = settings;
this.initializeDynamicStyles();
}
/**
* Updates the settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.updateDynamicStyles();
}
/**
* Creates the style element if it doesn't exist
*/
private initializeDynamicStyles(): void {
if (!this.dynamicStyleEl) {
this.dynamicStyleEl = document.createElement("style");
document.head.appendChild(this.dynamicStyleEl);
}
this.updateDynamicStyles();
}
/**
* Updates the dynamic styles based on current settings
*/
private updateDynamicStyles(): void {
if (!this.dynamicStyleEl) {
this.initializeDynamicStyles();
return;
}
const allColors = this.getAllStatusColors();
const cssRules = this.generateColorCssRules(allColors);
this.dynamicStyleEl.textContent = cssRules;
}
/**
* Get all status colors including those from enabled templates
*/
private getAllStatusColors(): Record<string, string> {
const colors = { ...this.settings.statusColors };
if (this.settings.useCustomStatusesOnly) return colors;
// Add colors from template statuses
for (const templateId of this.settings.enabledTemplates) {
const template = PREDEFINED_TEMPLATES.find(
(t) => t.id === templateId,
);
if (!template) continue;
for (const status of template.statuses) {
if (status.color && !colors[status.name]) {
colors[status.name] = status.color;
}
}
}
return colors;
}
/**
* Generate CSS rules for status colors
*/
private generateColorCssRules(colors: Record<string, string>): string {
let css = "";
for (const [status, color] of Object.entries(colors)) {
css += `
.status-${status} {
color: ${color} !important;
}
.note-status-bar .note-status-${status},
.nav-file-title .note-status-${status} {
color: ${color} !important;
}
`;
}
return css;
}
/**
* Cleans up styles when plugin is unloaded
*/
public unload(): void {
if (this.dynamicStyleEl) {
this.dynamicStyleEl.remove();
this.dynamicStyleEl = undefined;
}
}
}

1073
styles.css

File diff suppressed because one or more lines are too long

View file

@ -1,94 +0,0 @@
/*
* Base styles and variables for Note Status Plugin
*/
:root {
--status-transition-time: 0.22s;
--status-border-radius: var(--radius-s, 4px);
--status-box-shadow: var(--shadow-s, 0 2px 8px rgba(0, 0, 0, 0.15));
--status-hover-shadow: var(--shadow-m, 0 4px 12px rgba(0, 0, 0, 0.2));
--status-icon-size: 16px;
/* Animation curves */
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
}
/* Animations */
@keyframes note-status-scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes note-status-slide-in-fade {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes note-status-fade-in-slide-down {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes note-status-pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(0.98);
}
100% {
transform: scale(1);
}
}
/* Dark mode adjustments */
.theme-dark .note-status-popover {
box-shadow: var(--shadow-l);
}
.theme-dark .note-status-option-selecting {
box-shadow: 0 0 0 1px var(--interactive-accent);
}
/* High contrast improvements */
@media (forced-colors: active) {
.note-status-chip,
.note-status-option.is-selected {
border: 1px solid currentColor;
}
.note-status-action-button:focus-visible,
.note-status-chip:focus-visible,
.note-status-option:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
}
/* Print styles */
@media print {
.note-status-unified-dropdown,
.note-status-bar,
.note-status-pane {
display: none !important;
}
}

View file

@ -0,0 +1,11 @@
.collapsible-counter-container {
color: var(--text-muted);
padding: var(--size-2-1) var(--size-2-3);
borderradius: var(--radius-s);
backgroundcolor: var(--background-modifier-border);
border: 1px solid var(--background-modifier-border-hover);
fontweight: var(--font-weight-medium);
transition: all var(--anim-duration-fast) ease;
minwidth: 20px;
textalign: center;
}

View file

@ -0,0 +1,64 @@
.custom-status-card {
background: var(--background-secondary);
border-radius: var(--radius-m);
padding: var(--size-4-3);
margin-bottom: var(--size-4-2);
}
.custom-status-preview {
display: flex;
align-items: center;
gap: var(--size-4-2);
padding: var(--size-4-2);
background: var(--background-primary-alt);
border-radius: var(--radius-s);
border-left: 4px solid var(--text-muted);
}
.custom-status-icon-input {
width: 40px;
text-align: center;
font-size: 1.2em;
background: transparent;
border: none;
padding: 2px;
}
.custom-status-text-inputs {
flex: 1;
}
.custom-status-name-input {
background: transparent;
border: none;
color: var(--text-normal);
font-weight: var(--font-semibold);
font-size: var(--font-ui-medium);
width: 100%;
margin-bottom: 2px;
}
.custom-status-description-input {
background: transparent;
border: none;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
width: 100%;
}
.custom-status-controls {
display: flex;
align-items: center;
gap: var(--size-4-2);
}
.custom-status-color-input {
width: 32px;
height: 32px;
border-radius: var(--radius-s);
cursor: pointer;
}
.custom-status-remove-btn {
font-size: 16px;
}

View file

@ -1,263 +0,0 @@
/*
* Dropdown component styles
*/
.note-status-popover {
position: absolute;
background: var(--background-primary);
border-radius: var(--radius-m);
box-shadow: var(--status-box-shadow);
z-index: calc(var(--layer-popover) + 10);
max-height: 300px;
display: flex;
flex-direction: column;
border: 1px solid var(--background-modifier-border);
min-width: 200px;
overflow: hidden;
width: auto !important;
opacity: 0;
transform: scale(0.95);
transition:
opacity var(--status-transition-time) var(--ease-out),
transform var(--status-transition-time) var(--ease-out);
transform-origin: top left;
pointer-events: all !important;
}
.note-status-popover.note-status-popover-animate-in {
opacity: 1;
transform: scale(1);
}
.note-status-popover.note-status-popover-animate-out {
opacity: 0;
transform: scale(0.95);
pointer-events: none;
}
/* Search container */
.note-status-popover-search {
padding: 8px;
position: sticky;
top: 0;
background: var(--background-primary);
z-index: 3;
border-bottom: 1px solid var(--background-modifier-border);
}
/* Status options container */
.note-status-options-container {
overflow-y: auto;
overflow-x: hidden;
max-height: 250px;
padding: var(--size-4-1, 4px);
scrollbar-width: thin;
scrollbar-color: var(--background-modifier-border) transparent;
}
.note-status-options-container::-webkit-scrollbar {
width: 6px;
}
.note-status-options-container::-webkit-scrollbar-thumb {
background-color: var(--background-modifier-border);
border-radius: 3px;
}
.note-status-options-container::-webkit-scrollbar-track {
background-color: transparent;
}
/* Empty status options */
.note-status-empty-options {
padding: 16px 12px;
text-align: center;
color: var(--text-muted);
font-style: italic;
font-size: var(--font-smaller);
}
/* Status option items */
.note-status-option {
display: flex;
align-items: center;
padding: 8px 12px;
margin: 2px 4px;
border-radius: var(--radius-s);
background: var(--background-primary);
color: var(--text-normal);
cursor: pointer !important;
transition: background-color 0.15s ease;
}
.note-status-option:hover {
background: var(--background-modifier-hover);
}
.note-status-option.is-selected {
background: var(--background-secondary-alt);
font-weight: var(--font-medium);
}
.note-status-option-selecting {
background-color: var(--interactive-accent) !important;
color: var(--text-on-accent) !important;
animation: note-status-pulse 0.3s var(--ease-out);
}
.note-status-option-icon {
margin-right: 8px;
font-size: 1.1em;
}
.note-status-option-text {
flex: 1;
font-size: var(--font-smaller);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.note-status-option-check {
margin-left: auto;
color: var(--text-accent);
}
/* Unified dropdown */
.note-status-unified-dropdown {
width: 280px;
max-width: 90vw;
border-radius: var(--radius-m);
overflow: hidden;
box-shadow: var(--shadow-m);
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
z-index: 9999 !important;
pointer-events: all !important;
}
/* Animation for unified dropdown */
.note-status-popover-animate-in {
animation: note-status-dropdown-fade-in 0.22s var(--ease-out) forwards;
}
@keyframes note-status-dropdown-fade-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes note-status-dropdown-fade-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
/* Popover header */
.note-status-popover-header {
padding: 12px;
border-bottom: 1px solid var(--background-modifier-border);
background: var(--background-secondary-alt);
display: flex;
align-items: center;
position: sticky;
top: 0;
z-index: 2;
}
.note-status-popover-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: var(--font-semibold);
color: var(--text-normal);
}
.note-status-popover-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-accent);
}
.note-status-popover-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 12px;
background: var(--background-primary-alt);
max-height: 120px;
overflow-y: auto;
border-bottom: 1px solid var(--background-modifier-border);
}
/* Status chips styling */
.note-status-chip {
display: inline-flex;
align-items: center;
padding: 4px 10px;
background: var(--background-secondary);
border-radius: 16px;
box-shadow: var(--shadow-xs);
font-size: var(--font-smaller);
transition: all 0.15s var(--ease-out);
border: 1px solid var(--background-modifier-border);
max-width: 180px;
animation: note-status-scale-in 0.2s var(--ease-out);
}
.note-status-chip.clickable {
cursor: pointer;
}
.note-status-chip.clickable:hover {
background: var(--background-modifier-hover);
transform: translateY(-1px);
box-shadow: var(--status-hover-shadow);
}
.note-status-chip-removing {
transform: scale(0.8);
opacity: 0;
pointer-events: none;
transition: all 0.15s ease-out;
}
.note-status-chip-icon {
margin-right: 6px;
}
.note-status-chip-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.note-status-chip-remove {
margin-left: 6px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--background-modifier-border);
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s var(--ease-out);
}
.note-status-chip-remove:hover {
background: var(--text-accent);
color: var(--text-on-accent);
transform: scale(1.1);
}

Some files were not shown because too many files have changed in this diff Show more