Merge pull request #36 from devonthesofa/refactor/welcome-react

[Refactor] Migrate from Vanilla JS to React with UI improvements and new features
This commit is contained in:
Aleix Soler 2025-07-20 19:34:17 +02:00 committed by GitHub
commit 75ebd05d15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
132 changed files with 9822 additions and 6210 deletions

1
.obsidian/app.json vendored Normal file
View file

@ -0,0 +1 @@
{}

1
.obsidian/appearance.json vendored Normal file
View file

@ -0,0 +1 @@
{}

31
.obsidian/core-plugins.json vendored Normal file
View file

@ -0,0 +1,31 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"properties": false,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"webviewer": false
}

191
.obsidian/workspace.json vendored Normal file
View file

@ -0,0 +1,191 @@
{
"main": {
"id": "886965717aacb6d0",
"type": "split",
"children": [
{
"id": "9481425cee5d18ea",
"type": "tabs",
"children": [
{
"id": "1eb2e9c2c530a169",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "README.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "README"
}
},
{
"id": "87a5f93795884bb0",
"type": "leaf",
"state": {
"type": "empty",
"state": {},
"icon": "lucide-file",
"title": "New tab"
}
}
],
"currentTab": 1
}
],
"direction": "vertical"
},
"left": {
"id": "580f4711e49ff790",
"type": "split",
"children": [
{
"id": "2b68b0e26714f9db",
"type": "tabs",
"children": [
{
"id": "9a2de93776a5f52a",
"type": "leaf",
"state": {
"type": "file-explorer",
"state": {
"sortOrder": "alphabetical",
"autoReveal": true
},
"icon": "lucide-folder-closed",
"title": "Files"
}
},
{
"id": "c1f9ccb674013288",
"type": "leaf",
"state": {
"type": "search",
"state": {
"query": "",
"matchingCase": false,
"explainSearch": false,
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical"
},
"icon": "lucide-search",
"title": "Search"
}
},
{
"id": "1d6b9ea734b0a656",
"type": "leaf",
"state": {
"type": "bookmarks",
"state": {},
"icon": "lucide-bookmark",
"title": "Bookmarks"
}
}
]
}
],
"direction": "horizontal",
"width": 300
},
"right": {
"id": "267e362ff570d883",
"type": "split",
"children": [
{
"id": "beba4aa22886cb5b",
"type": "tabs",
"children": [
{
"id": "1f3a1273479a0100",
"type": "leaf",
"state": {
"type": "backlink",
"state": {
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical",
"showSearch": false,
"searchQuery": "",
"backlinkCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-coming-in",
"title": "Backlinks"
}
},
{
"id": "a50a231b68e4c2c8",
"type": "leaf",
"state": {
"type": "outgoing-link",
"state": {
"linksCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-going-out",
"title": "Outgoing links"
}
},
{
"id": "760ee1f1f13bf340",
"type": "leaf",
"state": {
"type": "tag",
"state": {
"sortOrder": "frequency",
"useHierarchy": true,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-tags",
"title": "Tags"
}
},
{
"id": "e8291e1c7436943b",
"type": "leaf",
"state": {
"type": "outline",
"state": {
"followCursor": false,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-list",
"title": "Outline"
}
}
]
}
],
"direction": "horizontal",
"width": 300
},
"left-ribbon": {
"hiddenItems": {
"switcher:Open quick switcher": false,
"graph:Open graph view": false,
"canvas:Create new canvas": false,
"daily-notes:Open today's daily note": false,
"templates:Insert template": false,
"command-palette:Open command palette": false
}
},
"active": "87a5f93795884bb0",
"lastOpenFiles": [
"README.md",
"wiki/User Manual.md",
"wiki/Quick Start Guide.md",
"wiki/Performance Tuning.md",
"wiki/Home Page.md",
"wiki/Frontmatter Format.md",
"wiki/Development Setup.md",
"wiki/Configuration Guide.md",
"wiki/Architecture Overview.md",
"wiki"
]
}

View file

@ -0,0 +1,58 @@
import React from "react";
import { GroupedStatuses, NoteStatus } from "@/types/noteStatus";
import {
StatusSelectorGroup,
Props as StatusSelectorGroupProps,
} from "./StatusSelectorGroup";
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 ChangeStatusModal: React.FC<Props> = ({
currentStatuses: initialStatuses,
filesQuantity,
availableStatuses,
onRemoveStatus,
onSelectStatus,
}) => {
const currentStatuses = Object.entries(initialStatuses);
const handleSelectedState: StatusSelectorGroupProps["onSelectedState"] = (
frontmatterTagName,
status,
action,
) => {
if (action === "select") {
onSelectStatus(frontmatterTagName, status);
} else {
onRemoveStatus(frontmatterTagName, status);
}
};
return (
<>
<h1>Change note status {filesQuantity}</h1>
{currentStatuses.map(([frontmatterTagName, statusList]) => (
<StatusSelectorGroup
key={frontmatterTagName}
frontmatterTagName={frontmatterTagName}
availableStatuses={availableStatuses}
currentStatuses={statusList}
onSelectedState={handleSelectedState}
/>
))}
</>
);
};

View file

@ -0,0 +1,39 @@
import React from "react";
import { NoteStatus } from "@/types/noteStatus";
import { StatusDisplay } from "../atoms/StatusDisplay";
import { SettingItem } from "../SettingsUI.tsx/SettingItem";
interface Props {
currentStatuses: NoteStatus[];
onRemoveStatus: (status: NoteStatus) => void;
}
export const CurrentStatusChips: React.FC<Props> = ({
currentStatuses,
onRemoveStatus,
}) => {
return (
<SettingItem name="Available statuses" vertical>
<div
className="note-status-chips"
style={{
display: "flex",
flexWrap: "wrap",
gap: "6px",
minHeight: "32px",
alignItems: "center",
}}
>
{currentStatuses.map((status) => (
<StatusDisplay
key={`${status.templateId || "custom"}:${status.name}`}
status={status}
variant="chip"
removable
onRemove={() => onRemoveStatus(status)}
/>
))}
</div>
</SettingItem>
);
};

View file

@ -0,0 +1,102 @@
import React, { useCallback } from "react";
import { NoteStatus } from "@/types/noteStatus";
import { SearchFilter } from "../atoms/SearchFilter";
import { StatusSelector } from "../atoms/StatusSelector";
import { SettingItem } from "../SettingsUI.tsx/SettingItem";
import { CurrentStatusChips } from "./CurrentStatusChips";
import { useKeyboardNavigation } from "./useKeyboardNavigation";
export interface Props {
frontmatterTagName: string;
currentStatuses: NoteStatus[];
availableStatuses: NoteStatus[];
onSelectedState: (
frontmatterTagName: string,
status: NoteStatus,
action: "select" | "unselected",
) => void;
}
export const StatusSelectorGroup: React.FC<Props> = ({
currentStatuses,
availableStatuses,
frontmatterTagName,
onSelectedState,
}) => {
const handleRemoveStatus = useCallback(
(status: NoteStatus) => {
onSelectedState(frontmatterTagName, status, "unselected");
},
[onSelectedState, frontmatterTagName],
);
const handleSelectStatus = useCallback(
(status: NoteStatus) => {
onSelectedState(frontmatterTagName, status, "select");
},
[onSelectedState, frontmatterTagName],
);
const {
focusedIndex,
searchFilter,
filteredStatuses,
containerRef,
searchRef,
handleKeyDown,
setSearchFilter,
} = useKeyboardNavigation({
availableStatuses,
currentStatuses,
onSelectStatus: handleSelectStatus,
onRemoveStatus: handleRemoveStatus,
});
return (
<div
ref={containerRef}
tabIndex={0}
onKeyDown={handleKeyDown}
style={{ outline: "none" }}
>
<SearchFilter
ref={searchRef}
value={searchFilter}
onFilterChange={setSearchFilter}
/>
<SettingItem name="Current statuses" vertical>
{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}
focusedIndex={focusedIndex}
onToggleStatus={(status, selected) =>
selected
? handleSelectStatus(status)
: handleRemoveStatus(status)
}
/>
)}
</SettingItem>
<CurrentStatusChips
currentStatuses={currentStatuses}
onRemoveStatus={handleRemoveStatus}
/>
</div>
);
};

View file

@ -0,0 +1,154 @@
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { NoteStatus } from "@/types/noteStatus";
interface UseKeyboardNavigationProps {
availableStatuses: NoteStatus[];
currentStatuses: NoteStatus[];
onSelectStatus: (status: NoteStatus) => void;
onRemoveStatus: (status: NoteStatus) => void;
}
export const useKeyboardNavigation = ({
availableStatuses,
currentStatuses,
onSelectStatus,
onRemoveStatus,
}: UseKeyboardNavigationProps) => {
const [focusedIndex, setFocusedIndex] = useState(-1);
const [searchFilter, setSearchFilter] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const filteredStatuses = useMemo(
() =>
searchFilter
? availableStatuses.filter((status) => {
const searchTerm = searchFilter.toLowerCase();
return (
status.name.toLowerCase().includes(searchTerm) ||
(status.templateId &&
status.templateId
.toLowerCase()
.includes(searchTerm))
);
})
: availableStatuses,
[searchFilter, availableStatuses],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (filteredStatuses.length > 0) {
setFocusedIndex((prev) =>
prev < filteredStatuses.length - 1 ? prev + 1 : 0,
);
}
break;
case "ArrowUp":
e.preventDefault();
if (filteredStatuses.length > 0) {
setFocusedIndex((prev) =>
prev > 0 ? prev - 1 : filteredStatuses.length - 1,
);
}
break;
case "Tab":
if (!e.shiftKey) {
e.preventDefault();
if (filteredStatuses.length > 0) {
setFocusedIndex((prev) =>
prev < filteredStatuses.length - 1
? prev + 1
: 0,
);
}
} else {
e.preventDefault();
if (filteredStatuses.length > 0) {
setFocusedIndex((prev) =>
prev > 0
? prev - 1
: filteredStatuses.length - 1,
);
}
}
break;
case "Enter":
if (
focusedIndex >= 0 &&
focusedIndex < filteredStatuses.length
) {
e.preventDefault();
const status = filteredStatuses[focusedIndex];
const isSelected = currentStatuses.some(
(s) =>
s.name === status.name &&
s.templateId === status.templateId,
);
if (isSelected) {
onRemoveStatus(status);
} else {
onSelectStatus(status);
}
}
break;
case "Backspace":
e.preventDefault();
setSearchFilter((prev) => prev.slice(0, -1));
if (searchRef.current) {
searchRef.current.focus();
}
break;
case "Escape":
e.preventDefault();
setSearchFilter("");
break;
default:
if (
e.key.length === 1 &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey
) {
e.preventDefault();
setSearchFilter((prev) => prev + e.key);
if (searchRef.current) {
searchRef.current.focus();
}
}
break;
}
},
[
filteredStatuses,
focusedIndex,
currentStatuses,
onRemoveStatus,
onSelectStatus,
searchRef,
],
);
useEffect(() => {
setFocusedIndex(filteredStatuses.length > 0 ? 0 : -1);
}, [searchFilter, filteredStatuses.length]);
useEffect(() => {
if (containerRef.current) {
containerRef.current.focus();
}
}, []);
return {
focusedIndex,
searchFilter,
filteredStatuses,
containerRef,
searchRef,
handleKeyDown,
setSearchFilter,
};
};

View file

@ -0,0 +1,66 @@
import { GroupedStatuses } from "@/types/noteStatus";
import React, { FC, memo } from "react";
type Props = {
statuses: GroupedStatuses;
onMouseEnter: (statuses: GroupedStatuses) => void;
onMouseLeave: (statuses: GroupedStatuses) => void;
hideUnknownStatus?: boolean;
};
export const FileExplorerIcon: FC<Props> = memo(
({ statuses, onMouseLeave, onMouseEnter, hideUnknownStatus }) => {
const statusEntries = Object.entries(statuses);
const totalStatuses = statusEntries.reduce(
(acc, [, list]) => acc + list.length,
0,
);
if (totalStatuses === 0) {
// If hideUnknownStatus is enabled, don't show anything for files without status
if (hideUnknownStatus) return null;
// Show "no status" icon
return (
<div className="status-wrapper">
<div
className="status-minimal status-minimal--no-status"
onMouseEnter={() => onMouseEnter({})}
onMouseLeave={() => onMouseLeave({})}
style={
{
"--primary-color": "var(--text-muted)",
} as React.CSSProperties
}
>
<span className="status-icon"></span>
</div>
</div>
);
}
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,169 @@
import { useState, useCallback, useMemo } from "react";
import { FilterSection } from "./components/FilterSection";
import { TagSection } from "./components/TagSection";
import { LoadingSpinner } from "./components/LoadingSpinner";
import {
GroupedDataProvider,
useGroupedDataContext,
} from "./context/GroupedDataProvider";
import { NoteStatus } from "@/types/noteStatus";
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 GroupedStatusViewProps = {
getAllFiles: () => FileItem[];
processFiles: (files: FileItem[]) => GroupedByStatus;
onFileClick: (file: FileItem) => void;
subscribeToEvents: (onDataChange: () => void) => () => void;
getAvailableStatuses: () => StatusItem[];
getAvailableStatusesWithTemplateInfo: () => NoteStatus[];
};
const GroupedStatusViewContent = () => {
const {
filteredData,
isLoading,
expandedGroups,
expandedFiles,
toggleGroup,
toggleFiles,
onFileClick,
getAvailableStatusesWithTemplateInfo,
getLoadedCount,
loadMoreItems,
handleScroll,
} = useGroupedDataContext();
const handleFileClickCallback = useCallback(
(file: FileItem) => {
onFileClick(file);
},
[onFileClick],
);
const statusMap = useMemo(() => {
const map = new Map();
const noteStatuses = getAvailableStatusesWithTemplateInfo();
// Build map using scoped identifiers as keys
noteStatuses.forEach((noteStatus) => {
const statusItem: StatusItem = {
name: noteStatus.name,
color: noteStatus.color || "white",
icon: noteStatus.icon,
};
const scopedIdentifier = noteStatus.templateId
? `${noteStatus.templateId}:${noteStatus.name}`
: noteStatus.name;
map.set(scopedIdentifier, statusItem);
});
return map;
}, [getAvailableStatusesWithTemplateInfo]);
if (isLoading) {
return <LoadingSpinner />;
}
return (
<div className="grouped-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="grouped-status-empty">No statuses found.</div>
)}
</div>
);
};
export const GroupedStatusView = ({
getAllFiles,
processFiles,
onFileClick,
subscribeToEvents,
getAvailableStatuses,
getAvailableStatusesWithTemplateInfo,
}: GroupedStatusViewProps) => {
const [searchFilter, setSearchFilter] = useState("");
const [noteNameFilter, setNoteNameFilter] = useState("");
const [templateFilter, setTemplateFilter] = useState("");
// Get available templates from the statuses
const availableTemplates = useMemo(() => {
const templates = new Set<string>();
getAvailableStatusesWithTemplateInfo().forEach((status) => {
if (status.templateId) {
templates.add(status.templateId);
}
});
return Array.from(templates).sort();
}, [getAvailableStatusesWithTemplateInfo]);
return (
<GroupedDataProvider
getAllFiles={getAllFiles}
processFiles={processFiles}
onFileClick={onFileClick}
subscribeToEvents={subscribeToEvents}
getAvailableStatuses={getAvailableStatuses}
getAvailableStatusesWithTemplateInfo={
getAvailableStatusesWithTemplateInfo
}
searchFilter={searchFilter}
noteNameFilter={noteNameFilter}
templateFilter={templateFilter}
>
<div className="">
<FilterSection
searchFilter={searchFilter}
noteNameFilter={noteNameFilter}
templateFilter={templateFilter}
availableTemplates={availableTemplates}
onSearchFilterChange={setSearchFilter}
onNoteNameFilterChange={setNoteNameFilter}
onTemplateFilterChange={setTemplateFilter}
/>
<GroupedStatusViewContent />
</div>
</GroupedDataProvider>
);
};

View file

@ -0,0 +1,82 @@
import React, { useCallback } from "react";
import { FileItem } from "../GroupedStatusView";
import { SelectableListItem } from "../../atoms/SelectableListItem";
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="grouped-status-files">
<div className="grouped-status-files-list" onScroll={handleScroll}>
{visibleFiles.map((file) => (
<SelectableListItem
key={file.id}
onClick={() => handleFileClick(file)}
className="grouped-status-file-item"
title={file.path}
>
<div>
<span className="grouped-status-file-name">
{file.name}
</span>
<span className="grouped-status-file-path">
{file.path}
</span>
</div>
</SelectableListItem>
))}
{hasMoreItems && (
<div className="grouped-status-load-more">
<button
className="grouped-status-load-btn"
onClick={handleLoadMore}
>
Load more... ({files.length - loadedCount}{" "}
remaining)
</button>
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,58 @@
import React from "react";
import { SearchFilter } from "@/components/atoms/SearchFilter";
import { Input } from "@/components/atoms/Input";
interface FilterSectionProps {
searchFilter: string;
noteNameFilter: string;
templateFilter: string;
availableTemplates: string[];
onSearchFilterChange: (value: string) => void;
onNoteNameFilterChange: (value: string) => void;
onTemplateFilterChange: (value: string) => void;
}
export const FilterSection = ({
searchFilter,
noteNameFilter,
templateFilter,
availableTemplates,
onSearchFilterChange,
onNoteNameFilterChange,
onTemplateFilterChange,
}: FilterSectionProps) => {
return (
<div className="grouped-status-header">
<h3 className="grouped-status-title">Status Groups</h3>
<div className="grouped-status-filters">
<SearchFilter
value={searchFilter}
onFilterChange={onSearchFilterChange}
/>
<div className="grouped-status-filters__note">
<Input
variant="text"
value={noteNameFilter}
onChange={onNoteNameFilterChange}
placeholder="Filter by note name..."
className="grouped-status-filters__note-input"
/>
</div>
<div className="grouped-status-filters__template">
<select
value={templateFilter}
onChange={(e) => onTemplateFilterChange(e.target.value)}
className="grouped-status-filters__template-select"
>
<option value="">All templates</option>
{availableTemplates.map((template) => (
<option key={template} value={template}>
{template}
</option>
))}
</select>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,9 @@
import React from "react";
export const LoadingSpinner = () => {
return (
<div className="">
<div className="grouped-status-loading">Loading statuses...</div>
</div>
);
};

View file

@ -0,0 +1,85 @@
import React, { useCallback } from "react";
import { StatusDisplay } from "@/components/atoms/StatusDisplay";
import { CollapsibleCounter } from "@/components/atoms/CollapsibleCounter";
import { FileItem, StatusItem } from "../GroupedStatusView";
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}`;
// Extract template ID from scoped identifier
const templateId = statusName.includes(":")
? statusName.split(":", 2)[0]
: null;
const handleToggle = useCallback(() => {
onToggle();
}, [onToggle]);
return (
<div className="grouped-status-group">
<div className="grouped-status-group-header" onClick={handleToggle}>
<div className="grouped-status-group__status">
<StatusDisplay
status={
{ ...status, icon: status.icon || "" } as NoteStatus
}
variant="badge"
/>
{templateId && (
<span className="grouped-status-group__template-badge">
{templateId}
</span>
)}
</div>
<div className="grouped-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 "../GroupedStatusView";
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="grouped-status-tag-section">
<div className="grouped-status-tag-header" onClick={handleToggle}>
<GroupLabel name={tag} />
<CollapsibleCounter
count={totalFilesInTag}
isCollapsed={!isExpanded}
onToggle={handleToggle}
/>
</div>
{isExpanded && (
<div className="grouped-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

@ -0,0 +1,111 @@
import React, { createContext, useContext, ReactNode } from "react";
import { FileItem, GroupedByStatus, StatusItem } from "../GroupedStatusView";
import { NoteStatus } from "@/types/noteStatus";
import { useGroupedData } from "../hooks/useGroupedData";
import { usePagination } from "../hooks/usePagination";
import { useExpandedState } from "../hooks/useExpandedState";
type GroupedDataContextType = {
// Data
groupedData: GroupedByStatus;
filteredData: GroupedByStatus;
isLoading: boolean;
loadData: () => void;
// Pagination
getLoadedCount: (groupKey: string) => number;
loadMoreItems: (groupKey: string) => void;
handleScroll: (
e: React.UIEvent<HTMLDivElement>,
groupKey: string,
totalItems: number,
) => void;
// Expanded state
expandedGroups: Set<string>;
expandedFiles: Set<string>;
toggleGroup: (groupKey: string) => void;
toggleFiles: (groupKey: string) => void;
// Props
onFileClick: (file: FileItem) => void;
getAvailableStatuses: () => StatusItem[];
getAvailableStatusesWithTemplateInfo: () => NoteStatus[];
};
const GroupedDataContext = createContext<GroupedDataContextType | undefined>(
undefined,
);
export const useGroupedDataContext = () => {
const context = useContext(GroupedDataContext);
if (!context) {
throw new Error(
"useGroupedDataContext must be used within GroupedDataProvider",
);
}
return context;
};
type GroupedDataProviderProps = {
children: ReactNode;
getAllFiles: () => FileItem[];
processFiles: (files: FileItem[]) => GroupedByStatus;
onFileClick: (file: FileItem) => void;
subscribeToEvents: (onDataChange: () => void) => () => void;
getAvailableStatuses: () => StatusItem[];
getAvailableStatusesWithTemplateInfo: () => NoteStatus[];
searchFilter: string;
noteNameFilter: string;
templateFilter: string;
};
export const GroupedDataProvider = ({
children,
getAllFiles,
processFiles,
onFileClick,
subscribeToEvents,
getAvailableStatuses,
getAvailableStatusesWithTemplateInfo,
searchFilter,
noteNameFilter,
templateFilter,
}: GroupedDataProviderProps) => {
const { groupedData, filteredData, isLoading, loadData } = useGroupedData({
getAllFiles,
processFiles,
subscribeToEvents,
searchFilter,
noteNameFilter,
templateFilter,
});
const { getLoadedCount, loadMoreItems, handleScroll } = usePagination();
const { expandedGroups, expandedFiles, toggleGroup, toggleFiles } =
useExpandedState(filteredData);
const value: GroupedDataContextType = {
groupedData,
filteredData,
isLoading,
loadData,
getLoadedCount,
loadMoreItems,
handleScroll,
expandedGroups,
expandedFiles,
toggleGroup,
toggleFiles,
onFileClick,
getAvailableStatuses,
getAvailableStatusesWithTemplateInfo,
};
return (
<GroupedDataContext.Provider value={value}>
{children}
</GroupedDataContext.Provider>
);
};

View file

@ -0,0 +1,3 @@
export { useGroupedData } from "./useGroupedData";
export { usePagination } from "./usePagination";
export { useExpandedState } from "./useExpandedState";

View file

@ -0,0 +1,66 @@
import { useState, useEffect, useCallback } from "react";
import { GroupedByStatus } from "../GroupedStatusView";
export const useExpandedState = (filteredData: GroupedByStatus) => {
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
new Set(),
);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
// 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;
});
}, []);
return {
expandedGroups,
expandedFiles,
toggleGroup,
toggleFiles,
};
};

View file

@ -0,0 +1,109 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { FileItem, GroupedByStatus } from "../GroupedStatusView";
export type UseGroupedDataProps = {
getAllFiles: () => FileItem[];
processFiles: (files: FileItem[]) => GroupedByStatus;
subscribeToEvents: (onDataChange: () => void) => () => void;
searchFilter: string;
noteNameFilter: string;
templateFilter: string;
};
export const useGroupedData = ({
getAllFiles,
processFiles,
subscribeToEvents,
searchFilter,
noteNameFilter,
templateFilter,
}: UseGroupedDataProps) => {
const [groupedData, setGroupedData] = useState<GroupedByStatus>({});
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() &&
!templateFilter.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]) => {
// Filter by template if templateFilter is provided
if (templateFilter.trim()) {
// Check if statusName contains the template (scoped: "template:status")
const statusTemplate = statusName.includes(":")
? statusName.split(":", 2)[0]
: "";
if (statusTemplate !== templateFilter) {
return; // Skip this status group if it doesn't match the template filter
}
}
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, templateFilter]);
return {
groupedData,
filteredData,
isLoading,
loadData,
};
};

View file

@ -0,0 +1,47 @@
import { useState, useCallback } from "react";
const ITEMS_PER_LOAD = 20;
export const usePagination = () => {
const [loadedItems, setLoadedItems] = useState<Record<string, number>>({});
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],
);
return {
loadedItems,
getLoadedCount,
loadMoreItems,
handleScroll,
};
};

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,137 @@
import { NoteStatus } from "@/types/noteStatus";
import { PluginSettings } from "@/types/pluginSettings";
import React from "react";
import { Input } from "@/components/atoms/Input";
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,
}) => {
const isValid = status.name.trim().length > 0;
const displayIcon = status.icon.trim() || "📝";
return (
<div className="custom-status-item">
{/* Simple horizontal layout with all inputs in a row */}
<div className="custom-status-item__row">
{/* Icon field - simple text input */}
<div className="custom-status-item__field">
<label className="custom-status-item__label">Icon</label>
<Input
variant="text"
value={status.icon}
onChange={(value) =>
onCustomStatusChange(index, "icon", value || "")
}
placeholder="📝"
className="custom-status-item__input custom-status-item__input--icon"
/>
</div>
{/* Name field */}
<div className="custom-status-item__field custom-status-item__field--name">
<label className="custom-status-item__label">
Name{" "}
<span className="custom-status-item__label--required">
*
</span>
</label>
<Input
variant="text"
value={status.name}
onChange={(value) =>
onCustomStatusChange(index, "name", value || "")
}
placeholder="e.g. In Progress"
className={`custom-status-item__input custom-status-item__input--name ${
!isValid ? "custom-status-item__input--invalid" : ""
}`}
/>
</div>
{/* Description field */}
<div className="custom-status-item__field custom-status-item__field--description">
<label className="custom-status-item__label">
Description
</label>
<Input
variant="text"
value={status.description || ""}
onChange={(value) =>
onCustomStatusChange(index, "description", value)
}
placeholder="Optional description"
className="custom-status-item__input custom-status-item__input--description"
/>
</div>
{/* Color picker */}
<div className="custom-status-item__field custom-status-item__field--color">
<label className="custom-status-item__label">Color</label>
<Input
variant="color"
value={status.color || "#888888"}
onChange={(value) =>
onCustomStatusChange(index, "color", value)
}
className="custom-status-item__input custom-status-item__input--color"
/>
</div>
{/* Remove button */}
<div className="custom-status-item__field custom-status-item__field--actions">
<button
onClick={() => onCustomStatusRemove(index)}
aria-label="Remove status"
className="custom-status-item__remove-btn"
title="Remove this status"
>
×
</button>
</div>
</div>
{/* Preview row - shows how the status will look */}
<div className="custom-status-item__preview">
<span
className="custom-status-item__preview-icon"
style={{ color: status.color }}
>
{displayIcon}
</span>
<span
className="custom-status-item__preview-text"
style={{ color: status.color }}
>
{status.name || "Status name"}
</span>
{status.description && (
<span className="custom-status-item__preview-desc">
{status.description}
</span>
)}
</div>
{/* Validation message */}
{!isValid && (
<div className="custom-status-item__error">
Please enter a status name
</div>
)}
</div>
);
};

View file

@ -0,0 +1,103 @@
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="Hide default template statuses and use only your custom statuses"
>
<input
type="checkbox"
checked={settings.useCustomStatusesOnly || false}
onChange={(e) =>
onChange("useCustomStatusesOnly", e.target.checked)
}
/>
</SettingItem>
<SettingItem
name="Your custom statuses"
description="Create custom statuses with icons (emoji/text), names, and colors. Each status needs a name to be valid."
vertical
>
<div className="custom-status-list">
{settings.customStatuses.length === 0 ? (
<div className="custom-status-list__empty">
<p>
No custom statuses yet. Click "Add Status" below
to create your first one.
</p>
</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="Create a new custom status"
>
<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,29 @@
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>
{description && (
<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.templateId || "custom"}:${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,48 @@
import React from "react";
import { StatusTemplate } from "@/types/pluginSettings";
import { StatusDisplay } from "../atoms/StatusDisplay";
import { SelectableListItem } from "../atoms/SelectableListItem";
interface TemplateItemProps {
template: StatusTemplate;
isEnabled: boolean;
onToggle: (templateId: string, enabled: boolean) => void;
}
export const TemplateItem: React.FC<TemplateItemProps> = ({
template,
isEnabled,
onToggle,
}) => (
<SelectableListItem
selected={isEnabled}
onClick={() => onToggle(template.id, !isEnabled)}
className={`template-item ${isEnabled ? "enabled" : ""}`}
icon={
<input
type="checkbox"
className="template-checkbox"
checked={isEnabled}
readOnly
/>
}
>
<div>
<div className="template-header">
<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) => (
<StatusDisplay
key={index}
status={status}
variant="template"
/>
))}
</div>
</div>
</SelectableListItem>
);

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,95 @@
import { PluginSettings } from "@/types/pluginSettings";
import React from "react";
import { Select } from "../atoms/Select";
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="Status icon in file explorer position"
description="Change the position of the icon"
>
<Select
options={[
{ display: "Filename left", value: "file-name-left" },
{ display: "Filename right", value: "file-name-right" },
{ display: "Absolute right", value: "absolute-right" },
]}
defaultValue={settings.fileExplorerIconPosition}
onChange={(value) =>
onChange("fileExplorerIconPosition", value)
}
/>
</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="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 { TemplateSettings } from "./TemplateSettings";
import { BehaviourSettings } from "./BehaviourSettings";
import { CustomStatusSettings } from "./CustomStatusSettings";
import { QuickCommandsSettings } from "./QuickCommandsSettings";
import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates";
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,55 @@
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 mod-clickable"
onClick={() => onStatusClick({ name: "", icon: "" })}
style={{ cursor: "pointer" }}
>
No status
</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,61 @@
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 { StatusDisplay } from "../atoms/StatusDisplay";
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}
>
<StatusDisplay
status={status}
variant="badge"
onClick={() => {
onStatusClick(status);
}}
/>
</div>
))}
{hiddenCount > 0 && (
<CollapsibleCounter
count={hiddenCount}
isCollapsed={!isUncollapsed}
onToggle={() => setIsUncollapsed(!isUncollapsed)}
/>
)}
</div>
</>
);
};

View file

@ -0,0 +1,78 @@
import { TFile } from "obsidian";
import { NoteStatus } from "@/types/noteStatus";
import { StatusDisplay } from "@/components/atoms/StatusDisplay";
import { GroupLabel } from "@/components/atoms/GroupLabel";
interface CurrentNoteInfo {
file: TFile | null;
statuses: Record<string, NoteStatus[]>;
lastModified: number;
}
interface CurrentNoteSectionProps {
currentNote: CurrentNoteInfo;
}
export const CurrentNoteSection = ({
currentNote,
}: CurrentNoteSectionProps) => {
return (
<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) => (
<StatusDisplay
key={`${status.templateId || "custom"}:${status.name}`}
status={status}
variant="badge"
/>
))
) : (
<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>
);
};

View file

@ -0,0 +1,160 @@
export type DashboardAction =
| "refresh"
| "open-grouped-view"
| "find-unassigned"
| "change-status"
| "cycle-status"
| "clear-status"
| "copy-status"
| "paste-status"
| "search-by-status"
| "search-by-specific-status"
| "toggle-multiple-mode"
| "set-quick-status";
interface QuickActionsPanelProps {
hasCurrentFile: boolean;
hasCurrentNoteStatuses: boolean;
useMultipleStatuses: boolean;
quickStatusCommands: string[];
onAction: (action: DashboardAction, value?: string) => void;
}
export const QuickActionsPanel = ({
hasCurrentFile,
hasCurrentNoteStatuses,
useMultipleStatuses,
quickStatusCommands,
onAction,
}: QuickActionsPanelProps) => {
return (
<div className="status-dashboard-section">
<div className="status-dashboard-section-header">
<h3>Quick Actions</h3>
</div>
<div className="quick-actions">
{/* Views Group */}
<div className="quick-actions-group">
<div className="quick-actions-group-title">📊 Views</div>
<button
className="quick-action-btn"
onClick={() => onAction("open-grouped-view")}
title="Open grouped status view"
>
📊 Grouped Statuses
</button>
<button
className="quick-action-btn"
onClick={() => onAction("find-unassigned")}
title="Find notes without status"
>
🔍 Find Unassigned Notes
</button>
</div>
{/* Quick Status Group - Only show when not in multi-status mode and has quick statuses */}
{!useMultipleStatuses && quickStatusCommands.length > 0 && (
<div className="quick-actions-group">
<div className="quick-actions-group-title">
Quick Status
</div>
{quickStatusCommands.map((statusName) => (
<button
key={statusName}
className="quick-action-btn"
onClick={() =>
onAction("set-quick-status", statusName)
}
disabled={!hasCurrentFile}
title={`Set status to ${statusName}`}
>
{statusName}
</button>
))}
</div>
)}
{/* Current Note Group */}
<div className="quick-actions-group">
<div className="quick-actions-group-title">
📝 Current Note
</div>
<button
className="quick-action-btn"
onClick={() => onAction("change-status")}
disabled={!hasCurrentFile}
title="Change status of current note"
>
📝 Change Status
</button>
<button
className="quick-action-btn"
onClick={() => onAction("cycle-status")}
disabled={!hasCurrentFile || useMultipleStatuses}
title="Cycle through available statuses"
>
📝 Cycle Status
</button>
<button
className="quick-action-btn"
onClick={() => onAction("clear-status")}
disabled={!hasCurrentFile}
title="Clear status from current note"
>
📝 Clear Status
</button>
</div>
{/* Clipboard Group */}
<div className="quick-actions-group">
<div className="quick-actions-group-title">
📋 Clipboard
</div>
<button
className="quick-action-btn"
onClick={() => onAction("copy-status")}
disabled={!hasCurrentFile}
title="Copy status to clipboard"
>
📋 Copy Status
</button>
<button
className="quick-action-btn"
onClick={() => onAction("paste-status")}
disabled={!hasCurrentFile}
title="Paste status from clipboard"
>
📋 Paste Status
</button>
</div>
{/* Tools Group */}
<div className="quick-actions-group">
<div className="quick-actions-group-title"> Tools</div>
<button
className="quick-action-btn"
onClick={() => onAction("search-by-status")}
disabled={!hasCurrentFile || !hasCurrentNoteStatuses}
title="Search for notes with same status"
>
Search by Status
</button>
<button
className="quick-action-btn"
onClick={() => onAction("toggle-multiple-mode")}
title="Toggle multiple statuses mode"
>
Toggle Multi-Status
</button>
<button
className="quick-action-btn"
onClick={() => onAction("refresh")}
title="Refresh dashboard data"
>
Refresh Data
</button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,83 @@
import { VaultStatsCard } from "./VaultStatsCard";
import { CurrentNoteSection } from "./CurrentNoteSection";
import { StatusDistributionChart } from "./StatusDistributionChart";
import { QuickActionsPanel, DashboardAction } from "./QuickActionsPanel";
import { VaultStats } from "./useVaultStats";
import { PluginSettings } from "@/types/pluginSettings";
import { TFile } from "obsidian";
import { NoteStatus } from "@/types/noteStatus";
interface CurrentNoteInfo {
file: TFile | null;
statuses: Record<string, NoteStatus[]>;
lastModified: number;
}
interface StatusDashboardProps {
onAction: (action: DashboardAction, value?: string) => void;
settings: PluginSettings;
vaultStats: VaultStats;
currentNote: CurrentNoteInfo;
isLoading: boolean;
availableStatuses: NoteStatus[];
}
export const StatusDashboard = ({
onAction,
settings,
vaultStats,
currentNote,
isLoading,
availableStatuses,
}: StatusDashboardProps) => {
if (isLoading) {
return (
<div className="status-dashboard">
<div className="status-dashboard-loading">
Loading dashboard...
</div>
</div>
);
}
// Check if current note has any statuses
const hasCurrentNoteStatuses =
currentNote.file &&
Object.values(currentNote.statuses).some(
(statuses) => statuses.length > 0,
);
return (
<div className="status-dashboard">
<div className="status-dashboard-header">
<h2 className="status-dashboard-title">Status Dashboard</h2>
<button
className="status-dashboard-refresh"
onClick={() => onAction("refresh")}
title="Refresh Dashboard"
>
🔄
</button>
</div>
<div className="status-dashboard-content">
<CurrentNoteSection currentNote={currentNote} />
<VaultStatsCard vaultStats={vaultStats} />
<StatusDistributionChart
vaultStats={vaultStats}
availableStatuses={availableStatuses}
onStatusClick={(statusName) =>
onAction("search-by-specific-status", statusName)
}
/>
<QuickActionsPanel
hasCurrentFile={!!currentNote.file}
hasCurrentNoteStatuses={!!hasCurrentNoteStatuses}
useMultipleStatuses={settings.useMultipleStatuses}
quickStatusCommands={settings.quickStatusCommands}
onAction={onAction}
/>
</div>
</div>
);
};

View file

@ -0,0 +1,126 @@
import { useMemo } from "react";
import { StatusDisplay } from "@/components/atoms/StatusDisplay";
import { VaultStats } from "./useVaultStats";
import { NoteStatus } from "@/types/noteStatus";
interface StatusDistributionChartProps {
vaultStats: VaultStats;
availableStatuses: NoteStatus[];
onStatusClick?: (statusName: string) => void;
}
export const StatusDistributionChart = ({
vaultStats,
availableStatuses,
onStatusClick,
}: StatusDistributionChartProps) => {
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 statusMap = useMemo(() => {
const map = new Map();
availableStatuses.forEach((s) => {
const scopedIdentifier = s.templateId
? `${s.templateId}:${s.name}`
: s.name;
map.set(scopedIdentifier, s);
});
// Also handle the case where we need to map legacy statuses to their first template
Object.keys(vaultStats.statusDistribution).forEach(
(statusIdentifier) => {
if (!map.has(statusIdentifier)) {
// This might be a legacy status that got assigned to a template
if (statusIdentifier.includes(":")) {
const [templateId, statusName] =
statusIdentifier.split(":");
const templateStatus = availableStatuses.find(
(s) =>
s.templateId === templateId &&
s.name === statusName,
);
if (templateStatus) {
map.set(statusIdentifier, templateStatus);
}
}
}
},
);
return map;
}, [availableStatuses, vaultStats.statusDistribution]);
return (
<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 ${onStatusClick ? "status-chart-item--clickable" : ""}`}
onClick={() => onStatusClick?.(name)}
title={
onStatusClick
? `Click to search for notes with status: ${name}`
: undefined
}
>
<div className="status-chart-info">
{status ? (
<StatusDisplay
status={status}
variant="badge"
/>
) : (
<span className="status-unknown-badge">
{name}
</span>
)}
<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>
);
};

View file

@ -0,0 +1,54 @@
import { VaultStats } from "./useVaultStats";
interface VaultStatsCardProps {
vaultStats: VaultStats;
}
export const VaultStatsCard = ({ vaultStats }: VaultStatsCardProps) => {
return (
<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>
);
};

View file

@ -0,0 +1,44 @@
import { useState, useCallback } from "react";
import { TFile } from "obsidian";
import { NoteStatus } from "@/types/noteStatus";
import {
BaseNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
interface CurrentNoteInfo {
file: TFile | null;
statuses: Record<string, NoteStatus[]>;
lastModified: number;
}
export const useCurrentNote = () => {
const [currentNote, setCurrentNote] = useState<CurrentNoteInfo>({
file: null,
statuses: {},
lastModified: 0,
});
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,
});
}, []);
return {
currentNote,
updateCurrentNote,
};
};

View file

@ -0,0 +1,121 @@
import { useState, useCallback } from "react";
import { TFile } from "obsidian";
import { NoteStatus } from "@/types/noteStatus";
import { BaseNoteStatusService } from "@/core/noteStatusService";
import settingsService from "@/core/settingsService";
export 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";
}>;
}
export const useVaultStats = () => {
const [vaultStats, setVaultStats] = useState<VaultStats>({
totalNotes: 0,
notesWithStatus: 0,
statusDistribution: {},
tagDistribution: {},
recentChanges: [],
});
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 distribution using full scoped identifiers
availableStatuses.forEach((status) => {
const scopedIdentifier = status.templateId
? `${status.templateId}:${status.name}`
: status.name;
statusDistribution[scopedIdentifier] = 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();
// Determine the scoped identifier to use
let scopedIdentifier: string;
if (statusStr.includes(":")) {
// Already scoped
scopedIdentifier = statusStr;
} else {
// Legacy status - find first template that has this status
const firstTemplateWithStatus =
availableStatuses.find(
(s) => s.name === statusStr && s.templateId,
);
scopedIdentifier = firstTemplateWithStatus
? `${firstTemplateWithStatus.templateId}:${statusStr}`
: statusStr; // Fallback to unscoped if no template found
}
// Initialize status if not already present
if (
!statusDistribution.hasOwnProperty(scopedIdentifier)
) {
statusDistribution[scopedIdentifier] = 0;
}
statusDistribution[scopedIdentifier]++;
});
}
});
if (hasAnyStatus) {
notesWithStatus++;
}
});
return {
totalNotes: files.length,
notesWithStatus,
statusDistribution,
tagDistribution,
recentChanges: [],
};
}, []);
const updateVaultStats = useCallback(() => {
const stats = calculateVaultStats();
setVaultStats(stats);
}, [calculateVaultStats]);
return {
vaultStats,
updateVaultStats,
};
};

View file

@ -0,0 +1,67 @@
import React from "react";
import { GroupedStatuses } from "@/types/noteStatus";
import { StatusDisplay } from "../atoms/StatusDisplay";
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"
>
<StatusDisplay
status={status}
variant="badge"
/>
{status.description && (
<div
className="status-description"
title={status.description}
>
{status.description}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
);
};

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,94 @@
import React from "react";
export type InputVariant = "text" | "color" | "search";
interface BaseInputProps {
variant: InputVariant;
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
style?: React.CSSProperties;
onFocus?: () => void;
onBlur?: () => void;
}
interface TextInputProps extends BaseInputProps {
variant: "text";
}
interface ColorInputProps extends BaseInputProps {
variant: "color";
}
interface SearchInputProps extends BaseInputProps {
variant: "search";
}
export type InputProps = TextInputProps | ColorInputProps | SearchInputProps;
const getInputStyles = (variant: InputVariant): React.CSSProperties => {
const baseStyles: React.CSSProperties = {
border: "1px solid var(--background-modifier-border)",
borderRadius: "4px",
backgroundColor: "var(--background-primary)",
color: "var(--text-normal)",
fontSize: "14px",
outline: "none",
};
switch (variant) {
case "text":
case "search":
return {
...baseStyles,
width: "100%",
padding: "8px 12px",
};
case "color":
return {
...baseStyles,
width: "32px",
height: "32px",
padding: "2px",
cursor: "pointer",
};
default:
return baseStyles;
}
};
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
{
variant,
value,
onChange,
placeholder,
className,
style,
onFocus,
onBlur,
},
ref,
) => {
const inputStyles = getInputStyles(variant);
const combinedStyles = { ...inputStyles, ...style };
return (
<input
ref={ref}
type={variant === "color" ? "color" : "text"}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={className}
style={combinedStyles}
onFocus={onFocus}
onBlur={onBlur}
/>
);
},
);
Input.displayName = "Input";

View file

@ -0,0 +1,24 @@
import React from "react";
import { Input } from "./Input";
interface Props {
value: string;
onFilterChange: (value: string) => void;
placeholder?: string;
}
export const SearchFilter = React.forwardRef<HTMLInputElement, Props>(
({ value, onFilterChange, placeholder = "Search..." }, ref) => {
return (
<Input
ref={ref}
variant="search"
value={value}
onChange={onFilterChange}
placeholder={placeholder}
/>
);
},
);
SearchFilter.displayName = "SearchFilter";

View file

@ -0,0 +1,26 @@
import React, { ReactNode } from "react";
type SelectProps = {
options: { value: string | number; display: ReactNode }[];
onChange: (value: string) => void;
defaultValue?: string;
};
export const Select: React.FC<SelectProps> = ({
options,
onChange,
defaultValue,
}) => {
return (
<select
defaultValue={defaultValue}
onChange={(e) => onChange(e.target.value)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.display}
</option>
))}
</select>
);
};

View file

@ -0,0 +1,100 @@
import React, { ReactNode, useState } from "react";
interface SelectableListItemProps {
selected?: boolean;
focused?: boolean;
icon?: ReactNode;
children: ReactNode;
onClick?: () => void;
className?: string;
title?: string;
}
export const SelectableListItem: React.FC<SelectableListItemProps> = ({
selected = false,
focused = false,
icon,
children,
onClick,
className = "",
title,
}) => {
const [isHovered, setIsHovered] = useState(false);
const handleClick = () => {
if (onClick) {
setTimeout(() => {
onClick();
}, 150);
}
};
return (
<div
className={`selectable-list-item ${className}`}
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
title={title}
style={{
display: "flex",
alignItems: "center",
gap: "12px",
padding: "8px 12px",
cursor: onClick ? "pointer" : "default",
borderBottom: "1px solid var(--background-modifier-border)",
transition: "background-color 150ms ease",
background:
selected || isHovered || focused
? "var(--background-modifier-hover)"
: "",
outline: focused
? "2px solid var(--interactive-accent)"
: "none",
outlineOffset: "-2px",
}}
>
{icon && (
<span
className="selectable-list-item-icon"
style={{
fontSize: "16px",
minWidth: "20px",
}}
>
{icon}
</span>
)}
<span
className="selectable-list-item-content"
style={{
flex: "1",
fontSize: "var(--font-ui-small)",
}}
>
{children}
</span>
{selected && (
<div
className="selectable-list-item-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,143 @@
import { NoteStatus } from "@/types/noteStatus";
import { FC, memo, useState } from "react";
import { getStatusTooltip } from "@/utils/statusUtils";
export type StatusDisplayVariant = "chip" | "badge" | "template";
interface StatusDisplayProps {
status: NoteStatus;
variant: StatusDisplayVariant;
removable?: boolean;
onRemove?: () => void;
onClick?: () => void;
}
export const StatusDisplay: FC<StatusDisplayProps> = memo(
({ status, variant, removable = false, onRemove, onClick }) => {
const [isRemoving, setIsRemoving] = useState(false);
const getDisplayName = () => {
return status.templateId
? `${status.name} (${status.templateId})`
: status.name;
};
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
if (onRemove) {
setIsRemoving(true);
setTimeout(() => {
onRemove();
}, 150);
}
};
const handleClick = () => {
if (onClick) {
onClick();
}
};
if (variant === "chip") {
return (
<div
className="note-status-chip"
title={getStatusTooltip(status)}
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: onClick ? "pointer" : "default",
transition: "all 150ms ease",
opacity: isRemoving ? "0.5" : "1",
}}
onClick={handleClick}
>
<span className="note-status-chip-icon">
{status.icon ? status.icon : "📝"}
</span>
<span className="note-status-chip-text">
{getDisplayName()}
</span>
{removable && (
<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>
);
}
if (variant === "badge") {
return (
<div
className="status-badge-container"
style={{
backgroundColor: `${status.color}15`,
border: `1px solid ${status.color}30`,
}}
onClick={handleClick}
>
<div className="status-badge-item">
<span className="status-badge-icon">
{status.icon ? status.icon : "📝"}
</span>
<span className="status-badge-text">
{getDisplayName()}
</span>
</div>
</div>
);
}
if (variant === "template") {
return (
<div className="template-status-chip" onClick={handleClick}>
<span
className="template-status-color-dot"
style={
{
"--dot-color": status.color,
} as React.CSSProperties
}
/>
<span>
{status.icon ? status.icon : "📝"} {getDisplayName()}
</span>
</div>
);
}
return null;
},
);

View file

@ -0,0 +1,79 @@
import React, { memo, useCallback } from "react";
import { NoteStatus } from "@/types/noteStatus";
import { SelectableListItem } from "./SelectableListItem";
import { getStatusTooltip, isStatusSelected } from "@/utils/statusUtils";
interface StatusOptionProps {
status: NoteStatus;
isSelected: boolean;
isFocused: boolean;
onSelect: () => void;
}
export const StatusModalOption: React.FC<StatusOptionProps> = memo(
({ status, isSelected, isFocused, onSelect }) => {
const displayName = status.templateId
? `${status.name} (${status.templateId})`
: status.name;
return (
<SelectableListItem
selected={isSelected}
focused={isFocused}
icon={status.icon}
onClick={onSelect}
className="note-status-option"
title={
status.description ? getStatusTooltip(status) : undefined
}
>
{displayName}
</SelectableListItem>
);
},
);
export interface Props {
currentStatuses: NoteStatus[];
availableStatuses: NoteStatus[];
focusedIndex?: number;
onToggleStatus: (status: NoteStatus, selected: boolean) => void;
}
export const StatusSelector: React.FC<Props> = ({
currentStatuses,
availableStatuses,
focusedIndex = -1,
onToggleStatus,
}) => {
const handleSelectStatus = useCallback(
async (status: NoteStatus) => {
const selected = isStatusSelected(status, currentStatuses);
onToggleStatus(status, !selected);
},
[currentStatuses, onToggleStatus],
);
return (
<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)",
}}
>
{availableStatuses.map((status, index) => (
<StatusModalOption
key={`${status.templateId || "custom"}:${status.name}:${status.description}:${status.color}:${status.icon}`}
status={status}
isSelected={isStatusSelected(status, currentStatuses)}
isFocused={index === focusedIndex}
onSelect={() => handleSelectStatus(status)}
/>
))}
</div>
);
};

View file

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

View file

@ -1,103 +0,0 @@
import { NoteStatusSettings } from "../../models/types";
import { StatusService } from "../../services/status-service";
import { StatusBarView } from "./status-bar-view";
/**
* Controller for the status bar
*/
export class StatusBarController {
private view: StatusBarView;
private settings: NoteStatusSettings;
private statusService: StatusService;
private currentStatuses: string[] = ["unknown"];
constructor(
statusBarContainer: HTMLElement,
settings: NoteStatusSettings,
statusService: StatusService,
) {
this.view = new StatusBarView(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
*/
private render(): void {
this.view.reset();
if (!this.settings.showStatusBar) {
this.view.hide();
return;
}
if (!this.settings.useMultipleStatuses) {
this.renderStatuses([this.currentStatuses[0]]);
} else {
this.renderStatuses(this.currentStatuses);
}
this.handleAutoHide();
}
/**
* Render statuses - handles both single and multiple status cases
*/
private renderStatuses(statuses: string[]): void {
const statusDetails = statuses.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,
};
});
this.view.renderStatuses(statusDetails);
}
/**
* Handle auto-hide behavior
*/
private handleAutoHide(): void {
const onlyUnknown =
this.currentStatuses.length === 1 &&
this.currentStatuses[0] === "unknown";
if (this.settings.autoHideStatusBar && onlyUnknown) {
this.view.hide();
} else {
this.view.show();
}
}
/**
* Update settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
this.render();
}
/**
* Clean up when plugin is unloaded
*/
public unload(): void {
this.view.destroy();
}
}

View file

@ -1,123 +0,0 @@
import { setTooltip } from "obsidian";
/**
* Renders the status bar UI
*/
export class StatusBarView {
private element: HTMLElement;
constructor(element: HTMLElement) {
this.element = element;
this.element.addClass("note-status-bar");
}
/**
* Clears the element and resets CSS classes
*/
reset(): void {
this.element.empty();
this.element.removeClass("left", "hidden", "auto-hide", "visible");
this.element.addClass("note-status-bar");
}
/**
* Hide the status bar
*/
hide(): void {
this.element.addClass("hidden");
this.element.removeClass("visible");
}
/**
* Show the status bar
*/
show(): void {
this.element.removeClass("hidden");
this.element.addClass("visible");
}
renderStatuses(
statuses: Array<{ name: string; icon: string; tooltipText: string }>,
): void {
if (statuses.length === 1) {
this.renderSingleStatus(
statuses[0].name,
statuses[0].icon,
statuses[0].tooltipText,
);
} else {
this.renderMultipleStatuses(statuses);
}
}
/**
* Render a single status
*/
private renderSingleStatus(
status: string,
icon: string,
tooltipText: string,
): void {
const statusText = this.element.createEl("span", {
text: `Status: ${status}`,
cls: `note-status-${status}`,
});
setTooltip(statusText, tooltipText);
const statusIcon = this.element.createEl("span", {
text: icon,
cls: `note-status-icon status-${status}`,
});
setTooltip(statusIcon, tooltipText);
}
/**
* Render multiple statuses
*/
private renderMultipleStatuses(
statuses: Array<{ name: string; icon: string; tooltipText: string }>,
): void {
this.element.createEl("span", {
text: "Statuses: ",
cls: "note-status-label",
});
const badgesContainer = this.element.createEl("span", {
cls: "note-status-badges",
});
statuses.forEach((status) =>
this.createStatusBadge(badgesContainer, status),
);
}
/**
* Create a status badge for multiple status display
*/
private createStatusBadge(
container: HTMLElement,
status: { name: string; icon: string; tooltipText: string },
): void {
const badge = container.createEl("span", {
cls: `note-status-badge status-${status.name}`,
});
setTooltip(badge, status.tooltipText);
badge.createEl("span", {
text: status.icon,
cls: "note-status-badge-icon",
});
badge.createEl("span", {
text: status.name,
cls: "note-status-badge-text",
});
}
/**
* Clean up the element
*/
destroy(): void {
this.element.empty();
}
}

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 { DropdownUI } from "./dropdown-ui";
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: DropdownUI;
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 DropdownUI(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,358 +0,0 @@
import { setIcon, TFile, setTooltip } from "obsidian";
import { StatusRemoveHandler, StatusSelectHandler } from "./types";
import { NoteStatusSettings, Status } from "models/types";
import { StatusService } from "services/status-service";
/**
* Render the dropdown content
*/
export function renderDropdownContent(options: {
dropdownElement: HTMLElement;
settings: NoteStatusSettings;
statusService: StatusService;
currentStatuses: string[];
targetFile: TFile | null;
targetFiles: TFile[];
onRemoveStatus: StatusRemoveHandler;
onSelectStatus: StatusSelectHandler;
}): void {
const {
dropdownElement,
settings,
statusService,
currentStatuses,
targetFile,
targetFiles,
onRemoveStatus,
onSelectStatus,
} = options;
dropdownElement.empty();
// Create UI sections
createHeader(dropdownElement, targetFiles);
const target = targetFiles.length > 1 ? targetFiles : targetFile;
createStatusChips(
dropdownElement,
currentStatuses,
statusService,
target ?? [],
onRemoveStatus,
);
const searchInput = createSearchFilter(dropdownElement);
// Create status options container
const statusOptionsContainer = dropdownElement.createDiv({
cls: "note-status-options-container",
});
// Get all available statuses (excluding 'unknown')
const allStatuses = statusService
.getAllStatuses()
.filter((status) => status.name !== "unknown");
// Function to populate options with filtering
const populateOptions = (filter = "") => {
populateStatusOptions({
container: statusOptionsContainer,
statuses: allStatuses,
currentStatuses,
settings,
targetFiles,
onSelectStatus,
filter,
});
};
// Initial population
populateOptions();
// Add search functionality
searchInput.addEventListener("input", () => {
populateOptions(searchInput.value);
});
// Focus search input after a short delay
setTimeout(() => searchInput.focus(), 50);
}
/**
* Create the dropdown header
*/
function createHeader(
dropdownElement: HTMLElement,
targetFiles: TFile[],
): void {
const headerEl = dropdownElement.createDiv({
cls: "note-status-popover-header",
});
const titleEl = headerEl.createDiv({ cls: "note-status-popover-title" });
const iconContainer = titleEl.createDiv({
cls: "note-status-popover-icon",
});
setIcon(iconContainer, "tag");
titleEl.createSpan({
text: "Note status",
cls: "note-status-popover-label",
});
// If multiple files are selected, show count
if (targetFiles.length > 1) {
titleEl.createSpan({
text: ` (${targetFiles.length} files)`,
cls: "note-status-popover-count",
});
}
}
/**
* Create the status chips section
*/
function createStatusChips(
dropdownElement: HTMLElement,
currentStatuses: string[],
statusService: StatusService,
targetFile: TFile | TFile[],
onRemoveStatus: StatusRemoveHandler,
): void {
const chipsContainer = dropdownElement.createDiv({
cls: "note-status-popover-chips",
});
const hasNoValidStatus =
currentStatuses.length === 0 ||
(currentStatuses.length === 1 && currentStatuses[0] === "unknown");
if (hasNoValidStatus) {
chipsContainer.createDiv({
cls: "note-status-empty-indicator",
text: "No status assigned",
});
} else {
createStatusChipElements(
chipsContainer,
currentStatuses,
statusService,
targetFile,
onRemoveStatus,
);
}
}
/**
* Create chips for all current statuses
*/
function createStatusChipElements(
container: HTMLElement,
currentStatuses: string[],
statusService: StatusService,
target: TFile | TFile[],
onRemoveStatus: StatusRemoveHandler,
): void {
currentStatuses.forEach((status) => {
if (status === "unknown") return;
const statusObj = statusService
.getAllStatuses()
.find((s) => s.name === status);
if (!statusObj) return;
createSingleStatusChip(
container,
status,
statusObj,
target,
onRemoveStatus,
);
});
}
/**
* Create a single status chip
*/
function createSingleStatusChip(
container: HTMLElement,
status: string,
statusObj: Status,
target: TFile | TFile[],
onRemoveStatus: StatusRemoveHandler,
): void {
const chipEl = container.createDiv({
cls: `note-status-chip status-${status}`,
});
// Status icon and name
chipEl.createSpan({
text: statusObj.icon,
cls: "note-status-chip-icon",
});
chipEl.createSpan({
text: statusObj.name,
cls: "note-status-chip-text",
});
addRemoveButton(chipEl, status, statusObj, target, onRemoveStatus);
}
/**
* Add a remove button to a status chip
*/
function addRemoveButton(
chipEl: HTMLElement,
status: string,
statusObj: Status,
targetFile: TFile | TFile[] | null,
onRemoveStatus: StatusRemoveHandler,
): void {
const tooltipValue = statusObj.description
? `${status} - ${statusObj.description}`
: status;
setTooltip(chipEl, tooltipValue);
const removeBtn = chipEl.createDiv({
cls: "note-status-chip-remove",
attr: {
"aria-label": `Remove ${status} status`,
title: `Remove ${status} status`,
},
});
setIcon(removeBtn, "x");
removeBtn.addEventListener("click", async (e) => {
e.stopPropagation();
chipEl.addClass("note-status-chip-removing");
setTimeout(async () => {
if (targetFile) {
await onRemoveStatus(status, targetFile);
}
}, 150);
});
}
/**
* Create the search filter input
*/
function createSearchFilter(dropdownElement: HTMLElement): HTMLInputElement {
const searchContainer = dropdownElement.createDiv({
cls: "note-status-popover-search",
});
return searchContainer.createEl("input", {
type: "text",
placeholder: "Filter statuses...",
cls: "note-status-popover-search-input",
});
}
/**
* Populate status options with optional filtering
*/
function populateStatusOptions(options: {
container: HTMLElement;
statuses: Status[];
currentStatuses: string[];
settings: NoteStatusSettings;
targetFiles: TFile[];
onSelectStatus: StatusSelectHandler;
filter?: string;
}): void {
const {
container,
statuses,
currentStatuses,
settings,
targetFiles,
onSelectStatus,
filter = "",
} = options;
container.empty();
const filteredStatuses = filter
? statuses.filter(
(status) =>
status.name.toLowerCase().includes(filter.toLowerCase()) ||
status.icon.includes(filter),
)
: statuses;
if (filteredStatuses.length === 0) {
container.createDiv({
cls: "note-status-empty-options",
text: filter
? `No statuses match "${filter}"`
: "No statuses found",
});
return;
}
filteredStatuses.forEach((status) => {
createStatusOption({
container,
status,
isSelected: currentStatuses.includes(status.name),
settings,
targetFiles,
onSelectStatus,
});
});
}
/**
* Create a single status option element
*/
function createStatusOption(options: {
container: HTMLElement;
status: Status;
isSelected: boolean;
settings: NoteStatusSettings;
targetFiles: TFile[];
onSelectStatus: StatusSelectHandler;
}): void {
const { container, status, isSelected, targetFiles, onSelectStatus } =
options;
const optionEl = container.createDiv({
cls: `note-status-option ${isSelected ? "is-selected" : ""} status-${status.name}`,
});
// Status icon and name
optionEl.createSpan({
text: status.icon,
cls: "note-status-option-icon",
});
optionEl.createSpan({
text: status.name,
cls: "note-status-option-text",
});
// Add tooltip if description available
if (status.description) {
setTooltip(optionEl, `${status.name} - ${status.description}`);
}
// Check icon for selected status
if (isSelected) {
const checkIcon = optionEl.createDiv({
cls: "note-status-option-check",
});
setIcon(checkIcon, "check");
}
optionEl.addEventListener("click", () => {
optionEl.addClass("note-status-option-selecting");
setTimeout(async () => {
if (targetFiles.length > 0) {
await onSelectStatus(status.name, targetFiles);
}
}, 150);
});
}

View file

@ -1,241 +0,0 @@
import { App, TFile } from "obsidian";
import {
DropdownDependencies,
StatusRemoveHandler,
StatusSelectHandler,
} from "./types";
import { positionDropdown } from "./dropdown-position";
import { renderDropdownContent } from "./dropdown-render";
import { setupDropdownEvents } from "./dropdown-events";
import { StatusService } from "services/status-service";
import { NoteStatusSettings } from "models/types";
/**
* Core UI component for the status dropdown
*/
export class DropdownUI {
private app: App;
private statusService: StatusService;
private settings: NoteStatusSettings;
private dropdownElement: HTMLElement | null = null;
private currentStatuses: string[] = ["unknown"];
private targetFile: TFile | null = null;
private targetFiles: TFile[] = [];
private animationDuration = 220;
public isOpen = false;
private onRemoveStatus: StatusRemoveHandler = async () => {};
private onSelectStatus: StatusSelectHandler = async () => {};
// Event handlers
private clickOutsideHandler: (e: MouseEvent) => void;
private escapeKeyHandler: (e: KeyboardEvent) => void;
constructor({ app, settings, statusService }: DropdownDependencies) {
this.app = app;
this.statusService = statusService;
this.settings = settings;
// Bind methods
this.clickOutsideHandler = this.handleClickOutside.bind(this);
this.escapeKeyHandler = this.handleEscapeKey.bind(this);
}
/**
* Set the target file for status updates
*/
public setTargetFile(file: TFile | null): void {
this.targetFile = file;
this.targetFiles = file ? [file] : [];
}
/**
* Set multiple target files for status updates
*/
public setTargetFiles(files: TFile[]): void {
this.targetFiles = [...files];
this.targetFile = files.length === 1 ? files[0] : null;
}
/**
* Register handler for removing a status
*/
public setOnRemoveStatusHandler(handler: StatusRemoveHandler): void {
this.onRemoveStatus = handler;
}
/**
* Register handler for selecting a status
*/
public setOnSelectStatusHandler(handler: StatusSelectHandler): void {
this.onSelectStatus = handler;
}
/**
* Updates the current statuses
*/
public updateStatuses(statuses: string[] | string): void {
this.currentStatuses = Array.isArray(statuses)
? [...statuses]
: [statuses];
if (this.isOpen && this.dropdownElement) {
this.refreshDropdownContent();
}
}
/**
* Updates settings reference
*/
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
if (this.isOpen && this.dropdownElement) {
this.refreshDropdownContent();
}
}
/**
* Toggle the dropdown visibility
*/
public toggle(
targetEl: HTMLElement,
position?: { x: number; y: number },
): void {
if (this.isOpen) {
this.close();
setTimeout(() => {
if (!this.isOpen && !this.dropdownElement) {
this.open(targetEl, position);
}
}, 50);
} else {
this.open(targetEl, position);
}
}
/**
* Open the dropdown
*/
public open(
targetEl: HTMLElement,
position?: { x: number; y: number },
): void {
if (this.isOpen || this.dropdownElement) {
this.close();
setTimeout(() => this.actuallyOpen(targetEl, position), 10);
return;
}
this.actuallyOpen(targetEl, position);
}
/**
* Actually open the dropdown (internal method)
*/
private actuallyOpen(
targetEl: HTMLElement,
position?: { x: number; y: number },
): void {
this.isOpen = true;
// Create dropdown element
this.createDropdownElement();
this.refreshDropdownContent();
// Position the dropdown
positionDropdown({
dropdownElement: this.dropdownElement!,
targetEl,
position,
});
this.dropdownElement?.addClass("note-status-popover-animate-in");
// Add event listeners with slight delay to prevent immediate triggering
setTimeout(() => {
setupDropdownEvents({
clickOutsideHandler: this.clickOutsideHandler,
escapeKeyHandler: this.escapeKeyHandler,
});
}, 10);
}
/**
* Create the dropdown element
*/
private createDropdownElement(): void {
this.dropdownElement = document.createElement("div");
this.dropdownElement.addClass(
"note-status-popover",
"note-status-unified-dropdown",
);
document.body.appendChild(this.dropdownElement);
}
/**
* Close the dropdown
*/
public close(): void {
if (!this.dropdownElement || !this.isOpen) return;
this.dropdownElement.addClass("note-status-popover-animate-out");
document.removeEventListener("click", this.clickOutsideHandler);
document.removeEventListener("keydown", this.escapeKeyHandler);
this.isOpen = false;
if (this.dropdownElement) {
this.dropdownElement.remove();
this.dropdownElement = null;
}
}
/**
* Refresh the dropdown content
*/
private refreshDropdownContent(): void {
if (!this.dropdownElement) return;
renderDropdownContent({
dropdownElement: this.dropdownElement,
settings: this.settings,
statusService: this.statusService,
currentStatuses: this.currentStatuses,
targetFile: this.targetFile,
targetFiles: this.targetFiles,
onRemoveStatus: this.onRemoveStatus,
onSelectStatus: this.onSelectStatus,
});
}
/**
* Handle click outside the dropdown
*/
private handleClickOutside(e: MouseEvent): void {
if (
this.dropdownElement &&
!this.dropdownElement.contains(e.target as Node)
) {
this.close();
}
}
/**
* Handle escape key to close dropdown
*/
private handleEscapeKey(e: KeyboardEvent): void {
if (e.key === "Escape") {
this.close();
}
}
/**
* Dispose of resources
*/
public dispose(): void {
this.close();
}
}

View file

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

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,85 +0,0 @@
import { NoteStatusSettings } from "models/types";
import { StatusService } from "services/status-service";
export class ToolbarButton {
private element: HTMLElement | null = null;
private settings: NoteStatusSettings;
private statusService: StatusService;
constructor(settings: NoteStatusSettings, statusService: StatusService) {
this.settings = settings;
this.statusService = statusService;
}
public createElement(): HTMLElement {
const button = document.createElement("button");
button.addClass(
"note-status-toolbar-button",
"clickable-icon",
"view-action",
);
button.setAttribute("aria-label", "Note status");
this.element = button;
return button;
}
public updateDisplay(statuses: string[]): void {
if (!this.element) return;
this.element.empty();
const hasValidStatus = statuses.length > 0 && statuses[0] !== "unknown";
const badgeContainer = document.createElement("div");
badgeContainer.addClass("note-status-toolbar-badge-container");
if (hasValidStatus) {
this.renderStatusBadge(badgeContainer, statuses);
} else {
this.renderUnknownBadge(badgeContainer);
}
this.element.appendChild(badgeContainer);
}
private renderStatusBadge(
container: HTMLElement,
statuses: string[],
): void {
const primaryStatus = statuses[0];
const icon = this.statusService.getStatusIcon(primaryStatus);
const iconSpan = document.createElement("span");
iconSpan.addClass(
`note-status-toolbar-icon`,
`status-${primaryStatus}`,
);
iconSpan.textContent = icon;
container.appendChild(iconSpan);
if (this.settings.useMultipleStatuses && statuses.length > 1) {
const countBadge = document.createElement("span");
countBadge.addClass("note-status-count-badge");
countBadge.textContent = `${statuses.length}`;
container.appendChild(countBadge);
}
}
private renderUnknownBadge(container: HTMLElement): void {
const iconSpan = document.createElement("span");
iconSpan.addClass("note-status-toolbar-icon", "status-unknown");
iconSpan.textContent = this.statusService.getStatusIcon("unknown");
container.appendChild(iconSpan);
}
public updateSettings(settings: NoteStatusSettings): void {
this.settings = settings;
}
public destroy(): void {
if (this.element) {
this.element.remove();
this.element = null;
}
}
}

View file

@ -1,10 +1,8 @@
import { NoteStatusSettings } from "../models/types";
import { DEFAULT_ENABLED_TEMPLATES } from "../constants/status-templates";
import { PluginSettings } from "types/pluginSettings";
import { DEFAULT_ENABLED_TEMPLATES } from "./predefinedTemplates";
/**
* Default plugin settings
*/
export const DEFAULT_SETTINGS: NoteStatusSettings = {
export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = {
fileExplorerIconPosition: "absolute-right",
statusColors: {
active: "var(--text-success)",
onHold: "var(--text-warning)",
@ -16,9 +14,7 @@ export const DEFAULT_SETTINGS: NoteStatusSettings = {
autoHideStatusBar: false,
customStatuses: [],
showStatusIconsInExplorer: true,
hideUnknownStatusInExplorer: false, // Default to show unknown status
collapsedStatuses: {},
compactView: false,
hideUnknownStatusInExplorer: true, // Default to hide unknown status
enabledTemplates: DEFAULT_ENABLED_TEMPLATES,
useCustomStatusesOnly: false,
useMultipleStatuses: true,
@ -27,14 +23,3 @@ export const DEFAULT_SETTINGS: NoteStatusSettings = {
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>`,
};

View file

@ -0,0 +1,209 @@
import { StatusTemplate } from "types/pluginSettings";
/**
* Predefined status templates
*/
export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
{
id: "colorful",
name: "Colorful workflow",
description:
"A colorful set of workflow statuses with descriptive icons",
statuses: [
{
name: "idea",
icon: "💡",
color: "#FFEB3B",
templateId: "colorful",
},
{
name: "draft",
icon: "📝",
color: "#E0E0E0",
templateId: "colorful",
},
{
name: "inProgress",
icon: "🔧",
color: "#FFC107",
templateId: "colorful",
},
{
name: "editing",
icon: "🖊️",
color: "#2196F3",
templateId: "colorful",
},
{
name: "pending",
icon: "⏳",
color: "#9C27B0",
templateId: "colorful",
},
{
name: "onHold",
icon: "⏸",
color: "#9E9E9E",
templateId: "colorful",
},
{
name: "needsUpdate",
icon: "🔄",
color: "#FF5722",
templateId: "colorful",
},
{
name: "completed",
icon: "✅",
color: "#4CAF50",
templateId: "colorful",
},
{
name: "archived",
icon: "📦",
color: "#795548",
templateId: "colorful",
},
],
},
{
id: "minimal",
name: "Minimal workflow",
description: "A simplified set of essential workflow statuses",
statuses: [
{
name: "todo",
icon: "📌",
color: "#F44336",
templateId: "minimal",
},
{
name: "inProgress",
icon: "⚙️",
color: "#2196F3",
templateId: "minimal",
},
{
name: "review",
icon: "👀",
color: "#9C27B0",
templateId: "minimal",
},
{
name: "done",
icon: "✓",
color: "#4CAF50",
templateId: "minimal",
},
],
},
{
id: "academic",
name: "Academic research",
description: "Status workflow for academic research and writing",
statuses: [
{
name: "research",
icon: "🔍",
color: "#2196F3",
templateId: "academic",
},
{
name: "outline",
icon: "📑",
color: "#9E9E9E",
templateId: "academic",
},
{
name: "draft",
icon: "✏️",
color: "#FFC107",
templateId: "academic",
},
{
name: "review",
icon: "🔬",
color: "#9C27B0",
templateId: "academic",
},
{
name: "revision",
icon: "📝",
color: "#FF5722",
templateId: "academic",
},
{
name: "final",
icon: "📚",
color: "#4CAF50",
templateId: "academic",
},
{
name: "published",
icon: "🎓",
color: "#795548",
templateId: "academic",
},
],
},
{
id: "project",
name: "Project management",
description: "Status workflow for project management and tracking",
statuses: [
{
name: "planning",
icon: "🗓️",
color: "#9E9E9E",
templateId: "project",
},
{
name: "backlog",
icon: "📋",
color: "#E0E0E0",
templateId: "project",
},
{
name: "ready",
icon: "🚦",
color: "#8BC34A",
templateId: "project",
},
{
name: "inDevelopment",
icon: "👨‍💻",
color: "#2196F3",
templateId: "project",
},
{
name: "testing",
icon: "🧪",
color: "#9C27B0",
templateId: "project",
},
{
name: "review",
icon: "👁️",
color: "#FFC107",
templateId: "project",
},
{
name: "approved",
icon: "👍",
color: "#4CAF50",
templateId: "project",
},
{
name: "live",
icon: "🚀",
color: "#3F51B5",
templateId: "project",
},
],
},
];
/**
* Default template IDs that should be enabled by default
*/
export const DEFAULT_ENABLED_TEMPLATES = ["colorful"];

View file

@ -1,79 +0,0 @@
import { Status } from "../models/types";
/**
* Status Template interface
*/
export interface StatusTemplate {
id: string;
name: string;
description: string;
statuses: Status[];
}
/**
* Predefined status templates
*/
export const PREDEFINED_TEMPLATES: StatusTemplate[] = [
{
id: "colorful",
name: "Colorful workflow",
description:
"A colorful set of workflow statuses with descriptive icons",
statuses: [
{ name: "idea", icon: "💡", color: "#FFEB3B" },
{ name: "draft", icon: "📝", color: "#E0E0E0" },
{ name: "inProgress", icon: "🔧", color: "#FFC107" },
{ name: "editing", icon: "🖊️", color: "#2196F3" },
{ name: "pending", icon: "⏳", color: "#9C27B0" },
{ name: "onHold", icon: "⏸", color: "#9E9E9E" },
{ name: "needsUpdate", icon: "🔄", color: "#FF5722" },
{ name: "completed", icon: "✅", color: "#4CAF50" },
{ name: "archived", icon: "📦", color: "#795548" },
],
},
{
id: "minimal",
name: "Minimal workflow",
description: "A simplified set of essential workflow statuses",
statuses: [
{ name: "todo", icon: "📌", color: "#F44336" },
{ name: "inProgress", icon: "⚙️", color: "#2196F3" },
{ name: "review", icon: "👀", color: "#9C27B0" },
{ name: "done", icon: "✓", color: "#4CAF50" },
],
},
{
id: "academic",
name: "Academic research",
description: "Status workflow for academic research and writing",
statuses: [
{ name: "research", icon: "🔍", color: "#2196F3" },
{ name: "outline", icon: "📑", color: "#9E9E9E" },
{ name: "draft", icon: "✏️", color: "#FFC107" },
{ name: "review", icon: "🔬", color: "#9C27B0" },
{ name: "revision", icon: "📝", color: "#FF5722" },
{ name: "final", icon: "📚", color: "#4CAF50" },
{ name: "published", icon: "🎓", color: "#795548" },
],
},
{
id: "project",
name: "Project management",
description: "Status workflow for project management and tracking",
statuses: [
{ name: "planning", icon: "🗓️", color: "#9E9E9E" },
{ name: "backlog", icon: "📋", color: "#E0E0E0" },
{ name: "ready", icon: "🚦", color: "#8BC34A" },
{ name: "inDevelopment", icon: "👨‍💻", color: "#2196F3" },
{ name: "testing", icon: "🧪", color: "#9C27B0" },
{ name: "review", icon: "👁️", color: "#FFC107" },
{ name: "approved", icon: "👍", color: "#4CAF50" },
{ name: "live", icon: "🚀", color: "#3F51B5" },
],
},
];
/**
* Default template IDs that should be enabled by default
*/
export const DEFAULT_ENABLED_TEMPLATES = ["colorful"];

314
core/commandsService.ts Normal file
View file

@ -0,0 +1,314 @@
import { Plugin, Notice, 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");
// 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();
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.findIndex(
(s) => s.name === currentStatus,
);
if (currentIndex !== -1) {
nextIndex =
currentIndex === -1
? 0
: (currentIndex + 1) % allStatuses.length;
}
}
const nextStatus = allStatuses[nextIndex];
const scopedIdentifier = nextStatus.templateId
? BaseNoteStatusService.formatStatusIdentifier({
templateId: nextStatus.templateId,
name: nextStatus.name,
})
: nextStatus.name;
statusService
.addStatus(
settingsService.settings.tagPrefix,
scopedIdentifier,
)
.then((resolve) => {
new Notice(`Status changed to ${nextStatus.name}`);
});
}
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(", ");
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 (settingsService.settings.useMultipleStatuses) {
return false;
}
if (
!checking &&
!settingsService.settings.useMultipleStatuses
) {
const statusService = this.createStatusService(file);
const scopedIdentifier = status.templateId
? BaseNoteStatusService.formatStatusIdentifier({
templateId: status.templateId,
name: status.name,
})
: status.name;
statusService
.overrideStatuses(
settingsService.settings.tagPrefix,
[scopedIdentifier],
)
.then(() => {
new Notice(`Status set to ${statusName}`);
});
}
return true;
},
});
this.registeredCommands.add(`set-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"];
}
/**
* Remove all quick status commands
*/
private removeQuickStatusCommands(): void {
const allStatuses = BaseNoteStatusService.getAllAvailableStatuses();
//TODO: If there is an update the
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}`,
);
});
}
public destroy(): void {
// Remove existing commands
this.removeQuickStatusCommands();
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();
}
}

540
core/noteStatusService.ts Normal file
View file

@ -0,0 +1,540 @@
import { App, TFile } from "obsidian";
import {
GroupedStatuses,
NoteStatus,
NoteStatus as NoteStatusType,
StatusIdentifier,
ScopedStatusName,
} from "@/types/noteStatus";
import settingsService from "@/core/settingsService";
import eventBus from "./eventBus";
import { PREDEFINED_TEMPLATES } from "@/constants/predefinedTemplates";
export abstract class BaseNoteStatusService {
static app: App;
statuses: GroupedStatuses;
constructor() {
this.statuses = {};
}
static initialize(app: App) {
BaseNoteStatusService.app = app;
}
static parseStatusIdentifier(
identifier: StatusIdentifier,
): ScopedStatusName {
if (typeof identifier === "string") {
if (identifier.includes(":")) {
const [templateId, name] = identifier.split(":", 2);
return { templateId, name };
}
return { name: identifier };
}
return identifier;
}
static formatStatusIdentifier(scopedName: ScopedStatusName): string {
if (scopedName.templateId) {
return `${scopedName.templateId}:${scopedName.name}`;
}
return scopedName.name;
}
static resolveStatusFromIdentifier(
identifier: StatusIdentifier,
): NoteStatus | undefined {
const parsed = BaseNoteStatusService.parseStatusIdentifier(identifier);
const availableStatuses =
BaseNoteStatusService.getAllAvailableStatuses();
if (parsed.templateId) {
return availableStatuses.find(
(s) =>
s.name === parsed.name &&
s.templateId === parsed.templateId,
);
}
return availableStatuses.find((s) => s.name === parsed.name);
}
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: string | StatusIdentifier) {
if (typeof statusName === "string") {
return BaseNoteStatusService.resolveStatusFromIdentifier(
statusName,
);
}
return BaseNoteStatusService.resolveStatusFromIdentifier(statusName);
}
protected getStatusMetadataKeys(): string[] {
return [settingsService.settings.tagPrefix];
}
abstract populateStatuses(): void;
abstract removeStatus(
frontmatterTagName: string,
status: NoteStatus,
): Promise<boolean>;
abstract addStatus(
frontmatterTagName: string,
statusIdentifier: StatusIdentifier,
): 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;
const targetIdentifier = status.templateId
? BaseNoteStatusService.formatStatusIdentifier({
templateId: status.templateId,
name: status.name,
})
: status.name;
await BaseNoteStatusService.app.fileManager.processFrontMatter(
this.file,
(frontmatter) => {
const noteStatusFrontmatter =
(frontmatter?.[frontmatterTagName] as string[]) || [];
if (!noteStatusFrontmatter.length) return;
if (Array.isArray(noteStatusFrontmatter)) {
// First try to find exact match (scoped or legacy)
let i = noteStatusFrontmatter.findIndex(
(statusName: string) => statusName === targetIdentifier,
);
// If not found and we're looking for a scoped status, try legacy format
if (i === -1 && status.templateId) {
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) {
frontmatter[frontmatterTagName] = [];
}
},
);
eventBus.publish("frontmatter-manually-changed", { file: this.file });
return true;
}
async overrideStatuses(
frontmatterTagName: string,
statusIdentifiers: StatusIdentifier[],
): Promise<boolean> {
const formattedIdentifiers = statusIdentifiers.map((id) =>
typeof id === "string"
? id
: BaseNoteStatusService.formatStatusIdentifier(id),
);
await BaseNoteStatusService.app.fileManager.processFrontMatter(
this.file,
(frontmatter) => {
if (
frontmatterTagName in frontmatter &&
Array.isArray(frontmatter[frontmatterTagName])
) {
frontmatter[frontmatterTagName].splice(0);
frontmatter[frontmatterTagName].push(
...formattedIdentifiers,
);
}
frontmatter[frontmatterTagName] = [...formattedIdentifiers];
},
);
eventBus.publish("frontmatter-manually-changed", { file: this.file });
return true;
}
async addStatus(
frontmatterTagName: string,
statusIdentifier: StatusIdentifier,
): Promise<boolean> {
let added = false;
const formattedIdentifier =
typeof statusIdentifier === "string"
? statusIdentifier
: BaseNoteStatusService.formatStatusIdentifier(
statusIdentifier,
);
await BaseNoteStatusService.app.fileManager.processFrontMatter(
this.file,
(frontmatter) => {
const noteStatusFrontmatter =
(frontmatter?.[frontmatterTagName] as string[]) || [];
if (!settingsService.settings.useMultipleStatuses) {
frontmatter[frontmatterTagName] = [formattedIdentifier];
added = true;
} else {
// Ensure frontmatter property exists as an array
if (
!frontmatter[frontmatterTagName] ||
!Array.isArray(frontmatter[frontmatterTagName])
) {
frontmatter[frontmatterTagName] = [];
}
// Check if we already have this status (exact match)
const exactMatch = noteStatusFrontmatter.findIndex(
(statusName: string) =>
statusName === formattedIdentifier,
);
if (exactMatch === -1) {
// Check if we have a legacy version of this scoped status
let legacyIndex = -1;
if (
typeof statusIdentifier !== "string" &&
statusIdentifier.templateId
) {
legacyIndex = noteStatusFrontmatter.findIndex(
(statusName: string) =>
statusName === statusIdentifier.name,
);
}
if (legacyIndex !== -1) {
// Replace legacy with scoped version
noteStatusFrontmatter[legacyIndex] =
formattedIdentifier;
added = true;
} else {
// Add new status
frontmatter[frontmatterTagName].push(
formattedIdentifier,
);
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 targetIdentifier = status.templateId
? BaseNoteStatusService.formatStatusIdentifier({
templateId: status.templateId,
name: status.name,
})
: status.name;
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)) {
// First try to find exact match (scoped or legacy)
let index = noteStatusFrontmatter.findIndex(
(statusName: string) =>
statusName === targetIdentifier,
);
// If not found and we're looking for a scoped status, try legacy format
if (index === -1 && status.templateId) {
index = noteStatusFrontmatter.findIndex(
(statusName: string) =>
statusName === status.name,
);
}
if (index !== -1) {
noteStatusFrontmatter.splice(index, 1);
removed = true;
}
} else if (
noteStatusFrontmatter === targetIdentifier ||
(status.templateId &&
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: StatusIdentifier,
): Promise<boolean> {
let addedToAny = false;
const formattedIdentifier =
typeof statusIdentifier === "string"
? statusIdentifier
: BaseNoteStatusService.formatStatusIdentifier(
statusIdentifier,
);
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] = [formattedIdentifier];
added = true;
} else if (Array.isArray(noteStatusFrontmatter)) {
if (
!noteStatusFrontmatter.includes(formattedIdentifier)
) {
noteStatusFrontmatter.push(formattedIdentifier);
added = true;
}
} else {
if (noteStatusFrontmatter !== formattedIdentifier) {
frontmatter[frontmatterTagName] = [
noteStatusFrontmatter,
formattedIdentifier,
];
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[] {
const targetIdentifier = status.templateId
? BaseNoteStatusService.formatStatusIdentifier({
templateId: status.templateId,
name: status.name,
})
: status.name;
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)) {
// Check for exact match first, then legacy format if scoped
return (
value.includes(targetIdentifier) ||
(status.templateId && value.includes(status.name))
);
}
// Check for exact match first, then legacy format if scoped
return (
value === targetIdentifier ||
(status.templateId && 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;

46
core/settingsService.ts Normal file
View file

@ -0,0 +1,46 @@
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`);
}
// const oldValue = this.settings[key]; // TODO: Send the old value
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

@ -26,7 +26,7 @@ const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
entryPoints: ["main.tsx"],
bundle: true,
external: [
"obsidian",
@ -51,6 +51,9 @@ const context = await esbuild.context({
treeShaking: true,
outfile: "main.js",
minify: prod,
jsx: "transform",
jsxFactory: "React.createElement",
jsxFragment: "React.Fragment",
});
// CSS context for watch mode

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,55 @@
import eventBus from "@/core/eventBus";
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();
eventBus.subscribe(
"plugin-settings-changed",
({ key }) => {
if (
key === "quickStatusCommands" ||
key === "useMultipleStatuses"
) {
this.commandsService.destroy();
/// BUG: if removed a command will persist because is not removed, you need the oldStates to send it to be disabled // const oldValue = this.settings[key]; // TODO: Send the old value
this.commandsService.registerAllCommands(); // INFO: Reset the registered commands
}
},
"commandsIntegrationSubscription2",
);
}
destroy(): void {
eventBus.unsubscribe(
"plugin-settings-changed",
"commandsIntegrationSubscription2",
);
if (this.commandsService) {
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("Change note state")
.setIcon("rotate-ccw") // Lucide icon
.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("Change note state")
.setIcon("rotate-ccw") // Lucide icon
.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("Change note state")
.setIcon("rotate-ccw") // Lucide icon
.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 { ToolbarButton } from "components/toolbar-button";
/**
* 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: ToolbarButton;
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 ToolbarButton(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,258 @@
import { FileExplorerIcon } from "@/components/FileExplorer/FileExplorerIcon";
import eventBus from "@/core/eventBus";
import {
LazyElementObserver,
IElementProcessor,
} from "@/core/lazyElementObserver";
import { NoteStatusService } from "@/core/noteStatusService";
import settingsService from "@/core/settingsService";
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,
);
eventBus.subscribe(
"plugin-settings-changed",
({ key }) => {
if (
key === "showStatusIconsInExplorer" ||
key === "hideUnknownStatusInExplorer" ||
key === "enabledTemplates" ||
key === "useCustomStatusesOnly" ||
key === "customStatuses" ||
key === "useMultipleStatuses" ||
key === "tagPrefix" ||
key === "strictStatuses" ||
key === "fileExplorerIconPosition"
) {
this.destroy();
this.integrate().catch((r) => console.error(r));
}
},
"fileExplorerIntegrationSubscription2",
);
}
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);
// Only render icons for markdown files
if (noteStatusService) {
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();
}
if (!settingsService.settings.showStatusIconsInExplorer) {
return;
}
let positionClassName = "";
if (
settingsService.settings.fileExplorerIconPosition ===
"absolute-right"
) {
positionClassName = "custom-icon__absolute-right";
}
const icon = createSpan({ cls: [this.ICON_CLASS, positionClassName] });
const root = createRoot(icon);
root.render(
<FileExplorerIcon
statuses={statuses}
onMouseEnter={(s) => this.openModalInfo(s)}
onMouseLeave={this.closeModalInfo}
hideUnknownStatus={
settingsService.settings.hideUnknownStatusInExplorer
}
/>,
);
if (
settingsService.settings.fileExplorerIconPosition ===
"file-name-right"
) {
element.append(icon);
} else {
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,
);
eventBus.unsubscribe(
"frontmatter-manually-changed",
"fileExplorerIntegrationSubscription2",
);
}
/**
* 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,139 @@
import { App, Modal, Notice } from "obsidian";
import { createRoot, Root } from "react-dom/client";
import {
ChangeStatusModal,
Props,
} from "@/components/ChangeStatusModal/ChangeStatusModal";
import SelectorService from "@/core/selectorService";
import {
MultipleNoteStatusService,
NoteStatusService,
BaseNoteStatusService,
} 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 scopedIdentifier = status.templateId
? BaseNoteStatusService.formatStatusIdentifier({
templateId: status.templateId,
name: status.name,
})
: status.name;
const added = await this.selectorService.noteStatusService.addStatus(
frontmatterTagName,
scopedIdentifier,
);
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 } = this;
if (!this.root) {
this.root = createRoot(contentEl);
}
let filesQuantity = 1;
if (
this.selectorService.noteStatusService instanceof
MultipleNoteStatusService
) {
filesQuantity =
this.selectorService.noteStatusService.selectedFilesQTY();
}
this.root.render(
<ChangeStatusModal
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

@ -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,138 +0,0 @@
import { App } from "obsidian";
import { Status } from "../../models/types";
import NoteStatus from "main";
import { StatusService } from "services/status-service";
import { NoteStatusSettingsUI } from "./settings-ui";
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: NoteStatusSettingsUI;
constructor(app: App, plugin: NoteStatus, statusService: StatusService) {
this.app = app;
this.plugin = plugin;
this.statusService = statusService;
this.ui = new NoteStatusSettingsUI(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 custom statuses section
const statusList = document.querySelector(
".custom-status-list",
) as HTMLElement;
if (statusList) {
this.ui.renderCustomStatuses(statusList, this.plugin.settings);
}
};
/**
* 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 custom statuses section
const statusList = document.querySelector(
".custom-status-list",
) as HTMLElement;
if (statusList) {
this.ui.renderCustomStatuses(statusList, this.plugin.settings);
}
};
}

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,479 +0,0 @@
import { Setting } from "obsidian";
import { NoteStatusSettings, Status } from "../../models/types";
import { PREDEFINED_TEMPLATES } from "../../constants/status-templates";
import { SettingsUICallbacks } from "./types";
/**
* Pure UI component for rendering settings interface
*/
export class NoteStatusSettingsUI {
private callbacks: SettingsUICallbacks;
private quickCommandsContainer: HTMLElement | null = null;
constructor(callbacks: SettingsUICallbacks) {
this.callbacks = callbacks;
}
/**
* Renders the complete settings interface
*/
render(containerEl: HTMLElement, settings: NoteStatusSettings): void {
containerEl.empty();
this.renderTemplateSettings(containerEl, settings);
this.renderUISettings(containerEl, settings);
this.renderTagSettings(containerEl, settings);
this.renderCustomStatusSettings(containerEl, settings);
this.renderQuickCommandsSettings(containerEl, settings);
}
/**
* Renders the status templates section
*/
private renderTemplateSettings(
containerEl: HTMLElement,
settings: NoteStatusSettings,
): void {
new Setting(containerEl).setName("Status templates").setHeading();
containerEl.createEl("p", {
text: "Enable predefined templates to quickly add common status workflows",
cls: "setting-item-description",
});
const templatesContainer = containerEl.createDiv({
cls: "templates-container",
});
PREDEFINED_TEMPLATES.forEach((template) => {
const templateEl = templatesContainer.createDiv({
cls: "template-item",
});
const headerEl = templateEl.createDiv({ cls: "template-header" });
const isEnabled = settings.enabledTemplates.includes(template.id);
const checkbox = headerEl.createEl("input", {
type: "checkbox",
cls: "template-checkbox",
});
checkbox.checked = isEnabled;
checkbox.addEventListener("change", () => {
this.callbacks.onTemplateToggle(template.id, checkbox.checked);
this.refreshQuickCommandsList(settings);
});
headerEl.createEl("span", {
text: template.name,
cls: "template-name",
});
templateEl.createEl("div", {
text: template.description,
cls: "template-description",
});
const statusesEl = templateEl.createDiv({
cls: "template-statuses",
});
template.statuses.forEach((status) => {
const statusEl = statusesEl.createEl("div", {
cls: "template-status-chip",
});
const colorDot = statusEl.createEl("span", {
cls: "status-color-dot",
});
colorDot.style.setProperty(
"--dot-color",
status.color || "#ffffff",
);
statusEl.createSpan({ text: `${status.icon} ${status.name}` });
});
});
}
/**
* Renders the UI display settings section
*/
private renderUISettings(
containerEl: HTMLElement,
settings: NoteStatusSettings,
): void {
new Setting(containerEl).setName("User interface").setHeading();
new Setting(containerEl)
.setName("Show status bar")
.setDesc("Display the status bar")
.addToggle((toggle) =>
toggle
.setValue(settings.showStatusBar)
.onChange((value) =>
this.callbacks.onSettingChange("showStatusBar", value),
),
);
new Setting(containerEl)
.setName("Auto-hide status bar")
.setDesc("Hide the status bar when status is unknown")
.addToggle((toggle) =>
toggle
.setValue(settings.autoHideStatusBar)
.onChange((value) =>
this.callbacks.onSettingChange(
"autoHideStatusBar",
value,
),
),
);
new Setting(containerEl)
.setName("Show status icons in file explorer")
.setDesc("Display status icons in the file explorer")
.addToggle((toggle) =>
toggle
.setValue(settings.showStatusIconsInExplorer)
.onChange((value) =>
this.callbacks.onSettingChange(
"showStatusIconsInExplorer",
value,
),
),
);
new Setting(containerEl)
.setName("Hide unknown status in file explorer")
.setDesc(
"Hide status icons for files with unknown status in the file explorer",
)
.addToggle((toggle) =>
toggle
.setValue(settings.hideUnknownStatusInExplorer || false)
.onChange((value) =>
this.callbacks.onSettingChange(
"hideUnknownStatusInExplorer",
value,
),
),
);
new Setting(containerEl)
.setName("Default to compact view")
.setDesc("Start the status pane in compact view by default")
.addToggle((toggle) =>
toggle
.setValue(settings.compactView || false)
.onChange((value) =>
this.callbacks.onSettingChange("compactView", value),
),
);
new Setting(containerEl)
.setName("Exclude unassigned notes from status pane")
.setDesc(
"Improves performance by excluding notes with no assigned status from the status pane. Recommended for large vaults.",
)
.addToggle((toggle) =>
toggle
.setValue(settings.excludeUnknownStatus || false)
.onChange((value) =>
this.callbacks.onSettingChange(
"excludeUnknownStatus",
value,
),
),
);
}
/**
* Renders the status tag configuration section
*/
private renderTagSettings(
containerEl: HTMLElement,
settings: NoteStatusSettings,
): void {
new Setting(containerEl).setName("Status tag").setHeading();
new Setting(containerEl)
.setName("Enable multiple statuses")
.setDesc("Allow notes to have multiple statuses at the same time")
.addToggle((toggle) =>
toggle
.setValue(settings.useMultipleStatuses)
.onChange((value) =>
this.callbacks.onSettingChange(
"useMultipleStatuses",
value,
),
),
);
new Setting(containerEl)
.setName("Status tag prefix")
.setDesc(
"The YAML frontmatter tag name used for status (default: obsidian-note-status)",
)
.addText((text) =>
text.setValue(settings.tagPrefix).onChange((value) => {
if (value.trim()) {
this.callbacks.onSettingChange(
"tagPrefix",
value.trim(),
);
}
}),
);
new Setting(containerEl)
.setName("Strict status validation")
.setDesc(
"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.",
)
.addToggle((toggle) =>
toggle
.setValue(settings.strictStatuses || false)
.onChange((value) =>
this.callbacks.onSettingChange("strictStatuses", value),
),
);
}
/**
* Renders the quick commands configuration section
*/
private renderQuickCommandsSettings(
containerEl: HTMLElement,
settings: NoteStatusSettings,
): void {
new Setting(containerEl)
.setName("Quick status commands")
.setDesc(
"Select which statuses should have dedicated commands in the command palette. These can be assigned hotkeys for quick access.",
)
.setHeading();
this.quickCommandsContainer = containerEl.createDiv({
cls: "quick-commands-container",
});
this.populateQuickCommandsList(settings);
}
/**
* Populate the quick commands list
*/
private populateQuickCommandsList(settings: NoteStatusSettings): void {
if (!this.quickCommandsContainer) return;
this.quickCommandsContainer.empty();
// Get all available statuses from service
const allStatuses = this.getAllAvailableStatuses(settings);
const currentQuickCommands = settings.quickStatusCommands || [];
allStatuses.forEach((status) => {
if (!this.quickCommandsContainer) return;
const setting = new Setting(this.quickCommandsContainer)
.setName(`${status.icon} ${status.name}`)
.addToggle((toggle) =>
toggle
.setValue(currentQuickCommands.includes(status.name))
.onChange(async (value) => {
const updatedCommands: string[] = value
? [
...currentQuickCommands.filter(
(cmd: string) =>
cmd !== status.name,
),
status.name,
]
: currentQuickCommands.filter(
(cmd: string) => cmd !== status.name,
);
await this.callbacks.onSettingChange(
"quickStatusCommands",
updatedCommands,
);
}),
);
if (status.description) {
setting.setDesc(status.description);
}
});
if (allStatuses.length === 0) {
this.quickCommandsContainer.createDiv({
text: "No statuses available. Enable templates or add custom statuses first.",
cls: "setting-item-description",
});
}
}
/**
* Refresh the quick commands list when statuses change
*/
private refreshQuickCommandsList(settings: NoteStatusSettings): void {
// Add small delay to ensure settings are updated
setTimeout(() => {
this.populateQuickCommandsList(settings);
}, 50);
}
/**
* Get all available statuses from templates and custom statuses
*/
private getAllAvailableStatuses(
settings: NoteStatusSettings,
): Array<{ name: string; icon: string; description?: string }> {
const statuses: Array<{
name: string;
icon: string;
description?: string;
}> = [];
// Add custom statuses
statuses.push(...settings.customStatuses);
// Add template statuses if not using custom only
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");
}
/**
* Renders the custom status management section
*/
private renderCustomStatusSettings(
containerEl: HTMLElement,
settings: NoteStatusSettings,
): void {
new Setting(containerEl).setName("Custom statuses").setHeading();
new Setting(containerEl)
.setName("Use only custom statuses")
.setDesc(
"Ignore template statuses and use only the custom statuses defined below",
)
.addToggle((toggle) =>
toggle
.setValue(settings.useCustomStatusesOnly || false)
.onChange(async (value) => {
await this.callbacks.onSettingChange(
"useCustomStatusesOnly",
value,
);
// Update quick commands list when custom-only mode changes
this.refreshQuickCommandsList(settings);
}),
);
const statusList = containerEl.createDiv({ cls: "custom-status-list" });
this.renderCustomStatuses(statusList, settings);
new Setting(containerEl)
.setName("Add new status")
.setDesc("Add a custom status with a name, icon, and color")
.addButton((button) =>
button
.setButtonText("Add Status")
.setCta()
.onClick(async () => {
await this.callbacks.onCustomStatusAdd();
this.refreshQuickCommandsList(settings);
}),
);
}
/**
* Renders the list of custom statuses with edit controls
*/
renderCustomStatuses(
statusList: HTMLElement,
settings: NoteStatusSettings,
): void {
statusList.empty();
settings.customStatuses.forEach((status: Status, index: number) => {
const setting = new Setting(statusList)
.setName(status.name)
.setClass("status-item");
setting.addText((text) =>
text
.setPlaceholder("Name")
.setValue(status.name)
.onChange(async (value) => {
await this.callbacks.onCustomStatusChange(
index,
"name",
value || "unnamed",
);
// Update quick commands list when status name changes
this.refreshQuickCommandsList(settings);
}),
);
setting.addText((text) =>
text
.setPlaceholder("Icon")
.setValue(status.icon)
.onChange(async (value) => {
await this.callbacks.onCustomStatusChange(
index,
"icon",
value || "❓",
);
this.refreshQuickCommandsList(settings);
}),
);
setting.addColorPicker((colorPicker) =>
colorPicker
.setValue(settings.statusColors[status.name] || "#ffffff")
.onChange((value) =>
this.callbacks.onCustomStatusChange(
index,
"color",
value,
),
),
);
setting.addText((text) =>
text
.setPlaceholder("Description")
.setValue(status.description || "")
.onChange(async (value) => {
await this.callbacks.onCustomStatusChange(
index,
"description",
value,
);
this.refreshQuickCommandsList(settings);
}),
);
setting.addButton((button) =>
button
.setButtonText("Remove")
.setClass("status-remove-button")
.setWarning()
.onClick(async () => {
await this.callbacks.onCustomStatusRemove(index);
this.refreshQuickCommandsList(settings);
}),
);
});
}
}

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",
({ 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,219 @@
import { ItemView, WorkspaceLeaf, TFile } from "obsidian";
import { Root, createRoot } from "react-dom/client";
import { GroupedStatusView as GroupedStatusViewComponent } from "@/components/GroupedStatusView/GroupedStatusView";
import { BaseNoteStatusService } from "@/core/noteStatusService";
import eventBus from "@/core/eventBus";
import settingsService from "@/core/settingsService";
import { NoteStatus } from "@/types/noteStatus";
export const VIEW_TYPE_GROUPED_DASHBOARD = "grouped-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 GroupedDashboardView extends ItemView {
root: Root | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType() {
return VIEW_TYPE_GROUPED_DASHBOARD;
}
getDisplayText() {
return "Grouped 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();
// Create maps for both scoped and legacy status lookup
const statusMap = new Map(
availableStatuses.map((s) => {
const key = s.templateId ? `${s.templateId}:${s.name}` : s.name;
return [key, s];
}),
);
const legacyStatusMap = new Map(
availableStatuses.map((s) => [s.name, s]),
);
statusMetadataKeys.forEach((key) => {
result[key] = {};
availableStatuses.forEach((status) => {
const statusKey = status.templateId
? `${status.templateId}:${status.name}`
: status.name;
result[key][statusKey] = [];
});
});
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();
// Try to find status by exact match first, then by legacy name
let resolvedStatus = statusMap.get(statusStr);
if (!resolvedStatus) {
resolvedStatus = legacyStatusMap.get(statusStr);
}
if (resolvedStatus) {
const statusKey = resolvedStatus.templateId
? `${resolvedStatus.templateId}:${resolvedStatus.name}`
: resolvedStatus.name;
if (!result[key][statusKey]) {
result[key][statusKey] = [];
}
result[key][statusKey].push(file);
}
});
}
});
});
return result;
};
private getAvailableStatuses = (): StatusItem[] => {
const statuses = BaseNoteStatusService.getAllAvailableStatuses();
return statuses.map(this.convertStatusToStatusItem);
};
private getAvailableStatusesWithTemplateInfo = () => {
return BaseNoteStatusService.getAllAvailableStatuses();
};
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,
"grouped-dashboard-view-subscription",
);
eventBus.subscribe(
"plugin-settings-changed",
({ key }) => {
if (
key === "tagPrefix" ||
key === "enabledTemplates" ||
key === "useCustomStatusesOnly" ||
key === "customStatuses" ||
key === "useMultipleStatuses" ||
key === "strictStatuses" ||
key === "excludeUnknownStatus"
) {
onDataChange();
}
},
"grouped-dashboard-view-settings-subscription",
);
const unsubscribeActiveFile = BaseNoteStatusService.app.workspace.on(
"active-leaf-change",
onDataChange,
);
return () => {
eventBus.unsubscribe(
"frontmatter-manually-changed",
"grouped-dashboard-view-subscription",
);
eventBus.unsubscribe(
"plugin-settings-changed",
"grouped-dashboard-view-settings-subscription",
);
BaseNoteStatusService.app.workspace.offref(unsubscribeActiveFile);
};
};
async onOpen() {
const container = this.containerEl.children[1];
container.empty();
container.addClass("grouped-dashboard-view-container");
this.root = createRoot(container);
this.root.render(
<GroupedStatusViewComponent
getAllFiles={this.getAllFiles}
processFiles={this.processFiles}
onFileClick={this.handleFileClick}
subscribeToEvents={this.subscribeToEvents}
getAvailableStatuses={this.getAvailableStatuses}
getAvailableStatusesWithTemplateInfo={
this.getAvailableStatusesWithTemplateInfo
}
/>,
);
}
async onClose() {
this.root?.unmount();
this.root = null;
}
}

View file

@ -0,0 +1,204 @@
import { ItemView, WorkspaceLeaf, TFile } from "obsidian";
import { Root, createRoot } from "react-dom/client";
import {
FileItem,
GroupedByStatus,
GroupedStatusView as GroupedStatusViewComponent,
StatusItem,
} from "@/components/GroupedStatusView/GroupedStatusView";
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 = "grouped-status-view";
export class GroupedStatusView extends ItemView {
root: Root | null = null;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType() {
return VIEW_TYPE_EXAMPLE;
}
getDisplayText() {
return "Grouped 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();
// Create maps for both scoped and legacy status lookup
const statusMap = new Map(
availableStatuses.map((s) => {
const key = s.templateId ? `${s.templateId}:${s.name}` : s.name;
return [key, s];
}),
);
const legacyStatusMap = new Map(
availableStatuses.map((s) => [s.name, s]),
);
statusMetadataKeys.forEach((key) => {
result[key] = {};
availableStatuses.forEach((status) => {
const statusKey = status.templateId
? `${status.templateId}:${status.name}`
: status.name;
result[key][statusKey] = [];
});
});
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();
// Try to find status by exact match first, then by legacy name
let resolvedStatus = statusMap.get(statusStr);
if (!resolvedStatus) {
resolvedStatus = legacyStatusMap.get(statusStr);
}
if (resolvedStatus) {
const statusKey = resolvedStatus.templateId
? `${resolvedStatus.templateId}:${resolvedStatus.name}`
: resolvedStatus.name;
if (!result[key][statusKey]) {
result[key][statusKey] = [];
}
result[key][statusKey].push(file);
}
});
}
});
});
return result;
};
private getAvailableStatuses = (): StatusItem[] => {
const statuses = BaseNoteStatusService.getAllAvailableStatuses();
return statuses.map(this.convertStatusToStatusItem);
};
private getAvailableStatusesWithTemplateInfo = () => {
return BaseNoteStatusService.getAllAvailableStatuses();
};
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,
"grouped-status-view-subscription",
);
eventBus.subscribe(
"plugin-settings-changed",
({ key }) => {
if (
key === "tagPrefix" ||
key === "enabledTemplates" ||
key === "useCustomStatusesOnly" ||
key === "customStatuses" ||
key === "useMultipleStatuses" ||
key === "strictStatuses" ||
key === "excludeUnknownStatus"
) {
onDataChange();
}
},
"grouped-status-view-settings-subscription",
);
const unsubscribeActiveFile = BaseNoteStatusService.app.workspace.on(
"active-leaf-change",
onDataChange,
);
return () => {
eventBus.unsubscribe(
"frontmatter-manually-changed",
"grouped-status-view-subscription",
);
eventBus.unsubscribe(
"plugin-settings-changed",
"grouped-status-view-settings-subscription",
);
BaseNoteStatusService.app.workspace.offref(unsubscribeActiveFile);
};
};
async onOpen() {
const container = this.containerEl.children[1];
container.empty();
container.addClass("grouped-status-view-container");
this.root = createRoot(container);
this.root.render(
<GroupedStatusViewComponent
getAllFiles={this.getAllFiles}
processFiles={this.processFiles}
onFileClick={this.handleFileClick}
subscribeToEvents={this.subscribeToEvents}
getAvailableStatuses={this.getAvailableStatuses}
getAvailableStatusesWithTemplateInfo={
this.getAvailableStatusesWithTemplateInfo
}
/>,
);
}
async onClose() {
this.root?.unmount();
this.root = null;
}
}

View file

@ -0,0 +1,371 @@
import { ItemView, WorkspaceLeaf, Notice, App, TFile } from "obsidian";
import { Root, createRoot } from "react-dom/client";
import { StatusDashboard } from "@/components/StatusDashboard/StatusDashboard";
import { DashboardAction } from "@/components/StatusDashboard/QuickActionsPanel";
import {
BaseNoteStatusService,
NoteStatusService,
} from "@/core/noteStatusService";
import settingsService from "@/core/settingsService";
import eventBus from "@/core/eventBus";
import { VaultStats } from "@/components/StatusDashboard/useVaultStats";
import { NoteStatus } from "@/types/noteStatus";
interface AppWithCommands extends App {
commands: {
executeCommandById(commandId: string): boolean;
};
}
interface CurrentNoteInfo {
file: TFile | null;
statuses: Record<string, NoteStatus[]>;
lastModified: number;
}
export const VIEW_TYPE_STATUS_DASHBOARD = "status-dashboard-view";
export class StatusDashboardView extends ItemView {
root: Root | null = null;
private vaultStats: VaultStats = {
totalNotes: 0,
notesWithStatus: 0,
statusDistribution: {},
tagDistribution: {},
recentChanges: [],
};
private currentNote: CurrentNoteInfo = {
file: null,
statuses: {},
lastModified: 0,
};
private isLoading: boolean = true;
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType() {
return VIEW_TYPE_STATUS_DASHBOARD;
}
getDisplayText() {
return "Status Dashboard";
}
getIcon() {
return "bar-chart-2";
}
private calculateVaultStats = (): VaultStats => {
const files = this.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 distribution using full scoped identifiers
availableStatuses.forEach((status) => {
const scopedIdentifier = status.templateId
? `${status.templateId}:${status.name}`
: status.name;
statusDistribution[scopedIdentifier] = 0;
});
statusMetadataKeys.forEach((tag) => {
tagDistribution[tag] = 0;
});
files.forEach((file) => {
const cachedMetadata = this.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();
// Determine the scoped identifier to use
let scopedIdentifier: string;
if (statusStr.includes(":")) {
// Already scoped
scopedIdentifier = statusStr;
} else {
// Legacy status - find first template that has this status
const firstTemplateWithStatus =
availableStatuses.find(
(s) => s.name === statusStr && s.templateId,
);
scopedIdentifier = firstTemplateWithStatus
? `${firstTemplateWithStatus.templateId}:${statusStr}`
: statusStr; // Fallback to unscoped if no template found
}
// Initialize status if not already present
if (
!statusDistribution.hasOwnProperty(scopedIdentifier)
) {
statusDistribution[scopedIdentifier] = 0;
}
statusDistribution[scopedIdentifier]++;
});
}
});
if (hasAnyStatus) {
notesWithStatus++;
}
});
return {
totalNotes: files.length,
notesWithStatus,
statusDistribution,
tagDistribution,
recentChanges: [],
};
};
private updateCurrentNote = () => {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
this.currentNote = { file: null, statuses: {}, lastModified: 0 };
this.renderDashboard();
return;
}
const noteStatusService = new NoteStatusService(activeFile);
noteStatusService.populateStatuses();
this.currentNote = {
file: activeFile,
statuses: noteStatusService.statuses,
lastModified: activeFile.stat.mtime,
};
this.renderDashboard();
};
private loadData = () => {
this.isLoading = true;
this.renderDashboard();
try {
this.vaultStats = this.calculateVaultStats();
this.updateCurrentNote();
} finally {
this.isLoading = false;
this.renderDashboard();
}
};
private handleAction = (action: DashboardAction, value?: string) => {
const appWithCommands = this.app as AppWithCommands;
switch (action) {
case "refresh":
this.loadData();
break;
case "open-grouped-view":
this.openGroupedView();
break;
case "find-unassigned":
this.findUnassignedNotes();
break;
case "change-status":
appWithCommands.commands.executeCommandById(
"note-status:change-status",
);
break;
case "cycle-status":
appWithCommands.commands.executeCommandById(
"note-status:cycle-status",
);
break;
case "clear-status":
appWithCommands.commands.executeCommandById(
"note-status:clear-status",
);
break;
case "copy-status":
appWithCommands.commands.executeCommandById(
"note-status:copy-status",
);
break;
case "paste-status":
appWithCommands.commands.executeCommandById(
"note-status:paste-status",
);
break;
case "search-by-status":
appWithCommands.commands.executeCommandById(
"note-status:search-by-status",
);
break;
case "toggle-multiple-mode":
appWithCommands.commands.executeCommandById(
"note-status:toggle-multiple-statuses",
);
break;
case "set-quick-status":
if (value) {
const commandId = `note-status:set-status-${value}`;
const result =
appWithCommands.commands.executeCommandById(commandId);
if (!result) {
new Notice(
`Quick status command failed: ${value}. Make sure the status exists and multiple statuses mode is disabled.`,
);
}
}
break;
case "search-by-specific-status":
if (value) {
this.searchBySpecificStatus(value);
}
break;
}
};
private openGroupedView() {
const leaf = this.app.workspace.getLeaf();
leaf.setViewState({ type: "grouped-status-view", active: true });
}
private findUnassignedNotes() {
const files = this.app.vault.getMarkdownFiles();
const filesWithoutStatus = files.filter((file) => {
const cachedMetadata = this.app.metadataCache.getFileCache(file);
const frontmatter = cachedMetadata?.frontmatter;
return (
!frontmatter || !frontmatter[settingsService.settings.tagPrefix]
);
});
if (filesWithoutStatus.length === 0) {
new Notice("All notes have status assigned!");
return;
}
new Notice(
`Found ${filesWithoutStatus.length} notes without status. Opening search...`,
);
// Create a search query to find files without the status tag
const tagPrefix = settingsService.settings.tagPrefix;
const query = `-[${tagPrefix}:]`;
// @ts-ignore
this.app.internalPlugins.plugins[
"global-search"
].instance.openGlobalSearch(query);
}
private searchBySpecificStatus(statusName: string) {
const tagPrefix = settingsService.settings.tagPrefix;
const query = `[${tagPrefix}:"${statusName}"]`;
// @ts-ignore
this.app.internalPlugins.plugins[
"global-search"
].instance.openGlobalSearch(query);
}
private renderDashboard() {
if (!this.root) return;
this.root.render(
<StatusDashboard
onAction={this.handleAction}
settings={settingsService.settings}
vaultStats={this.vaultStats}
currentNote={this.currentNote}
isLoading={this.isLoading}
availableStatuses={BaseNoteStatusService.getAllAvailableStatuses()}
/>,
);
}
async onOpen() {
const container = this.containerEl.children[1];
container.empty();
container.addClass("status-dashboard-view-container");
this.root = createRoot(container);
// Set up event listeners
const handleFileChange = () => {
this.updateCurrentNote();
};
const handleVaultChange = () => {
this.loadData();
};
eventBus.subscribe(
"frontmatter-manually-changed",
handleVaultChange,
"status-dashboard-vault-subscription",
);
eventBus.subscribe(
"active-file-change",
handleFileChange,
"status-dashboard-file-subscription",
);
eventBus.subscribe(
"plugin-settings-changed",
({ key }) => {
if (
key === "tagPrefix" ||
key === "enabledTemplates" ||
key === "useCustomStatusesOnly" ||
key === "customStatuses" ||
key === "useMultipleStatuses" ||
key === "strictStatuses" ||
key === "excludeUnknownStatus"
) {
handleVaultChange();
handleFileChange();
}
},
"status-dashboard-settings-subscription",
);
this.app.workspace.on("active-leaf-change", handleFileChange);
// Initial load
this.loadData();
}
async onClose() {
// Clean up event listeners
eventBus.unsubscribe(
"frontmatter-manually-changed",
"status-dashboard-vault-subscription",
);
eventBus.unsubscribe(
"active-file-change",
"status-dashboard-file-subscription",
);
eventBus.unsubscribe(
"plugin-settings-changed",
"status-dashboard-settings-subscription",
);
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);
}
}
}

267
main.ts
View file

@ -1,267 +0,0 @@
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";
// 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";
// Importar componentes UI
import { StatusBar } from "components/status-bar";
import { StatusDropdown } from "components/status-dropdown";
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: StatusDropdown;
// Integraciones
explorerIntegration: ExplorerIntegration;
fileContextMenuIntegration: FileContextMenuIntegration;
editorIntegration: EditorIntegration;
toolbarIntegration: ToolbarIntegration;
metadataIntegration: MetadataIntegration;
workspaceIntegration: WorkspaceIntegration;
commandIntegration: CommandIntegration;
statusPane: StatusPaneViewController;
private boundHandleStatusChanged: (event: CustomEvent) => void;
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.",
);
}
}
private async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData(),
);
}
private initializeServices() {
this.statusService = new StatusService(this.app, this.settings);
this.styleService = new StyleService(this.settings);
}
private registerViews() {
// Register status pane view
this.registerView("status-pane", (leaf) => {
this.statusPane = new StatusPaneViewController(leaf, this);
return this.statusPane;
});
// Add ribbon icon
this.addRibbonIcon("tag", "Open status pane", () => {
this.openStatusPane();
});
// Añadir pestaña de configuración
this.addSettingTab(
new NoteStatusSettingTab(this.app, this, this.statusService),
);
}
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,
);
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,
);
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 StatusDropdown(
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);
}
}
}
private async openStatusPane() {
await StatusPaneViewController.open(this.app);
}
async saveSettings() {
await this.saveData(this.settings);
// 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,
);
}
// Clean up integrations
this.explorerIntegration?.unload();
this.toolbarIntegration?.unload();
this.fileContextMenuIntegration?.unload();
this.workspaceIntegration?.unload();
this.metadataIntegration?.unload();
this.editorIntegration?.unload();
this.commandIntegration?.unload();
// Clean up services
this.styleService?.unload();
// Clean up UI components
this.statusBar?.unload();
this.statusDropdown?.unload();
}
}

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